Упрощение git: только просмотр с diff, без словного уровня, render без побочек
- История: убран таб текстового diff — только Просмотр версии с подсветкой; пустые состояния через emptyMessage - GitManager: удалены fileDiffText/fileDiffHtml (+escHtml), остался fileContentAt/workdirContent/history/restore - MdParser: удалён словный merge (плейсхолдеры/tokenize/risiko), текст — отдельными del/add как diff; renderInner без сброса codeBlocks, render() — clear+renderInner; убраны save/restore хаки - Тесты обновлены
This commit is contained in:
@@ -28,9 +28,9 @@ Dialog {
|
|||||||
property var historyModel: []
|
property var historyModel: []
|
||||||
property int selectedIndex: -1
|
property int selectedIndex: -1
|
||||||
property string selectedHash: ""
|
property string selectedHash: ""
|
||||||
property string diffHtml: ""
|
|
||||||
property string versionContent: ""
|
property string versionContent: ""
|
||||||
property string currentContent: ""
|
property string currentContent: ""
|
||||||
|
property string emptyMessage: qsTr("Нет версии для просмотра")
|
||||||
property bool busy: false
|
property bool busy: false
|
||||||
|
|
||||||
function fileDirOf(p) {
|
function fileDirOf(p) {
|
||||||
@@ -62,7 +62,7 @@ Dialog {
|
|||||||
historyModel = []
|
historyModel = []
|
||||||
versionContent = ""
|
versionContent = ""
|
||||||
currentContent = ""
|
currentContent = ""
|
||||||
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Файл не выбран") + "</p>"
|
emptyMessage = qsTr("Файл не выбран")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
busy = true
|
busy = true
|
||||||
@@ -79,16 +79,11 @@ Dialog {
|
|||||||
selectedHash = ""
|
selectedHash = ""
|
||||||
versionContent = ""
|
versionContent = ""
|
||||||
currentContent = ""
|
currentContent = ""
|
||||||
const hasFile = filePath.length>0
|
const st = gitManager.fileGitStatus(vaultPath, filePath)
|
||||||
if (hasFile) {
|
if (st === "неотслеживается" || st === "новый") {
|
||||||
const st = gitManager.fileGitStatus(vaultPath, filePath)
|
emptyMessage = qsTr("История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия.")
|
||||||
if (st === "неотслеживается" || st === "новый") {
|
|
||||||
diffHtml = "<p style='color:" + Theme.warningText + "'>" + qsTr("История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия.") + "</p>"
|
|
||||||
} else {
|
|
||||||
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет версий для этого файла") + "</p>"
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
diffHtml = ""
|
emptyMessage = qsTr("Нет версий для этого файла")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
busy = false
|
busy = false
|
||||||
@@ -96,16 +91,12 @@ Dialog {
|
|||||||
|
|
||||||
function updateDiff() {
|
function updateDiff() {
|
||||||
if (selectedHash.length===0) {
|
if (selectedHash.length===0) {
|
||||||
diffHtml = ""
|
|
||||||
versionContent = ""
|
versionContent = ""
|
||||||
currentContent = ""
|
currentContent = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
|
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
|
||||||
currentContent = gitManager.workdirContent(filePath)
|
currentContent = gitManager.workdirContent(filePath)
|
||||||
const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash)
|
|
||||||
if (html.length===0) diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет изменений") + "</p>"
|
|
||||||
else diffHtml = html
|
|
||||||
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
|
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
|
||||||
// с подсветкой отличий от текущего файла прямо там
|
// с подсветкой отличий от текущего файла прямо там
|
||||||
versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
|
versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
|
||||||
@@ -292,87 +283,48 @@ Dialog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Правая панель — diff и превью
|
// Правая панель — версия как кнопка «Просмотр», с подсветкой отличий прямо там
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
SplitView.fillWidth: true
|
SplitView.fillWidth: true
|
||||||
SplitView.minimumWidth: 300
|
SplitView.minimumWidth: 300
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
TabBar {
|
Rectangle {
|
||||||
id: tabBar
|
|
||||||
Layout.fillWidth: true
|
|
||||||
TabButton { text: qsTr("Изменения") }
|
|
||||||
TabButton { text: qsTr("Просмотр версии") }
|
|
||||||
}
|
|
||||||
|
|
||||||
StackLayout {
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
currentIndex: tabBar.currentIndex
|
color: Theme.editorBg
|
||||||
|
border.color: Theme.divider
|
||||||
// Diff вкладка
|
ScrollView {
|
||||||
Rectangle {
|
id: versionScroll
|
||||||
color: Theme.editorBg
|
anchors.fill: parent
|
||||||
border.color: Theme.divider
|
anchors.margins: 6
|
||||||
ScrollView {
|
clip: true
|
||||||
anchors.fill: parent
|
ScrollBar.vertical: ScrollBar {}
|
||||||
anchors.margins: 6
|
Column {
|
||||||
clip: true
|
width: versionScroll.availableWidth
|
||||||
ScrollBar.vertical: ScrollBar {}
|
MarkdownView {
|
||||||
TextArea {
|
id: versionPreview
|
||||||
id: diffArea
|
width: parent.width
|
||||||
readOnly: true
|
markdown: root.versionContent
|
||||||
textFormat: Text.RichText
|
compareMarkdown: root.currentContent
|
||||||
wrapMode: Text.Wrap
|
vaultRoot: root.vaultPath
|
||||||
color: Theme.text
|
fileDir: root.fileDirOf(root.filePath)
|
||||||
selectionColor: Theme.selectionBg
|
emptyText: root.emptyMessage
|
||||||
text: root.diffHtml
|
onLinkActivated: (href) => root.handleHistoryLink(href)
|
||||||
background: Rectangle { color: Theme.editorBg }
|
|
||||||
font.family: "Menlo, monospace"
|
|
||||||
font.pixelSize: 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Просмотр версии — как кнопка «Просмотр» в редакторе
|
|
||||||
Rectangle {
|
|
||||||
color: Theme.editorBg
|
|
||||||
border.color: Theme.divider
|
|
||||||
ScrollView {
|
|
||||||
id: versionScroll
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 6
|
|
||||||
clip: true
|
|
||||||
ScrollBar.vertical: ScrollBar {}
|
|
||||||
Column {
|
|
||||||
width: versionScroll.availableWidth
|
|
||||||
MarkdownView {
|
|
||||||
id: versionPreview
|
|
||||||
width: parent.width
|
|
||||||
markdown: root.versionContent
|
|
||||||
compareMarkdown: root.currentContent
|
|
||||||
vaultRoot: root.vaultPath
|
|
||||||
fileDir: root.fileDirOf(root.filePath)
|
|
||||||
emptyText: qsTr("Нет версии для просмотра")
|
|
||||||
onLinkActivated: (href) => root.handleHistoryLink(href)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Легенда — цвета отличий от текущего файла (обе вкладки)
|
// Легенда — цвета отличий от текущего файла
|
||||||
Row {
|
Row {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.margins: 6
|
Layout.margins: 6
|
||||||
spacing: 12
|
spacing: 12
|
||||||
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:"#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 { 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 {
|
Label {
|
||||||
text: tabBar.currentIndex === 0
|
text: qsTr("Версия с подсветкой отличий от текущего файла")
|
||||||
? qsTr("Сравнение выбранной версии с текущим файлом")
|
|
||||||
: qsTr("Версия с подсветкой отличий от текущего файла")
|
|
||||||
color: Theme.textFaint
|
color: Theme.textFaint
|
||||||
font.pixelSize: 10
|
font.pixelSize: 10
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|||||||
@@ -41,23 +41,6 @@ QString timeAgoString(const QDateTime &dt)
|
|||||||
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
|
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
|
||||||
}
|
}
|
||||||
|
|
||||||
QString escHtml(const QString &s)
|
|
||||||
{
|
|
||||||
QString out;
|
|
||||||
out.reserve(s.size());
|
|
||||||
for (const QChar &c : s) {
|
|
||||||
switch (c.unicode()) {
|
|
||||||
case '&': out += "&"; break;
|
|
||||||
case '<': out += "<"; break;
|
|
||||||
case '>': out += ">"; break;
|
|
||||||
case '"': out += """; break;
|
|
||||||
case '\'': out += "'"; break;
|
|
||||||
default: out += c; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool GitManager::s_gitInitialized = false;
|
bool GitManager::s_gitInitialized = false;
|
||||||
@@ -505,90 +488,6 @@ QString GitManager::fileContentAt(const QString &vaultPath, const QString &fileP
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString GitManager::fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
|
||||||
{
|
|
||||||
const QString oldContent = fileContentAt(vaultPath, filePath, commitHash);
|
|
||||||
QFile f(filePath);
|
|
||||||
QString newContent;
|
|
||||||
if (f.open(QIODevice::ReadOnly))
|
|
||||||
newContent = QString::fromUtf8(f.readAll());
|
|
||||||
else
|
|
||||||
newContent = QString();
|
|
||||||
|
|
||||||
if (oldContent == newContent)
|
|
||||||
return QStringLiteral("Нет изменений");
|
|
||||||
|
|
||||||
// Используем git_patch_from_buffers для диффа
|
|
||||||
git_patch *patch = nullptr;
|
|
||||||
const QByteArray oldBytes = oldContent.toUtf8();
|
|
||||||
const QByteArray newBytes = newContent.toUtf8();
|
|
||||||
const QString rel = relativePath(vaultPath, filePath);
|
|
||||||
const QByteArray relBytes = rel.toUtf8();
|
|
||||||
git_patch_from_buffers(&patch,
|
|
||||||
oldBytes.constData(), (size_t)oldBytes.size(), relBytes.constData(),
|
|
||||||
newBytes.constData(), (size_t)newBytes.size(), relBytes.constData(),
|
|
||||||
nullptr);
|
|
||||||
if (!patch)
|
|
||||||
return QStringLiteral("Не удалось построить diff");
|
|
||||||
|
|
||||||
QString out;
|
|
||||||
// Заголовок
|
|
||||||
out += QStringLiteral("--- a/%1\n").arg(rel);
|
|
||||||
out += QStringLiteral("+++ b/%1\n").arg(rel);
|
|
||||||
|
|
||||||
size_t hunks = git_patch_num_hunks(patch);
|
|
||||||
for (size_t h = 0; h < hunks; ++h) {
|
|
||||||
const git_diff_hunk *hk = nullptr;
|
|
||||||
size_t hunkLines = 0;
|
|
||||||
if (git_patch_get_hunk(&hk, &hunkLines, patch, h) < 0) continue;
|
|
||||||
out += QString::fromUtf8(hk->header, (int)hk->header_len);
|
|
||||||
if (!out.endsWith('\n')) out += '\n';
|
|
||||||
for (size_t l = 0; l < hunkLines; ++l) {
|
|
||||||
const git_diff_line *line = nullptr;
|
|
||||||
if (git_patch_get_line_in_hunk(&line, patch, h, l) < 0) continue;
|
|
||||||
QChar origin = QChar(line->origin);
|
|
||||||
QString text = QString::fromUtf8(line->content, (int)line->content_len);
|
|
||||||
// line->content уже содержит \n в конце
|
|
||||||
out += origin + text;
|
|
||||||
if (!text.endsWith('\n') && line->content_len > 0) out += '\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
git_patch_free(patch);
|
|
||||||
if (out.trimmed().isEmpty())
|
|
||||||
return QStringLiteral("Нет изменений");
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
|
||||||
{
|
|
||||||
const QString txt = fileDiffText(vaultPath, filePath, commitHash);
|
|
||||||
if (txt == QStringLiteral("Нет изменений") || txt.startsWith(QStringLiteral("Не удалось")))
|
|
||||||
return QStringLiteral("<p style='color:#888'>%1</p>").arg(escHtml(txt));
|
|
||||||
|
|
||||||
QString html;
|
|
||||||
html.reserve(txt.size()*2);
|
|
||||||
html += "<pre style='font-family:Menlo,monospace;font-size:12px;white-space:pre-wrap;word-wrap:break-word;margin:0'>";
|
|
||||||
const QStringList lines = txt.split('\n');
|
|
||||||
for (const QString &rawLine : lines) {
|
|
||||||
if (rawLine.isEmpty()) { html += "\n"; continue; }
|
|
||||||
QChar first = rawLine.at(0);
|
|
||||||
QString esc = escHtml(rawLine);
|
|
||||||
if (first == '+') {
|
|
||||||
html += QStringLiteral("<div style='background:rgba(34,197,94,0.45);color:#0d5c2c'>%1</div>").arg(esc);
|
|
||||||
} else if (first == '-') {
|
|
||||||
html += QStringLiteral("<div style='background:rgba(244,63,94,0.45);color:#b3192e'>%1</div>").arg(esc);
|
|
||||||
} else if (first == '@') {
|
|
||||||
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
|
|
||||||
} else if (first == ' ') {
|
|
||||||
html += QStringLiteral("<div style='color:#24292f'>%1</div>").arg(esc);
|
|
||||||
} else {
|
|
||||||
html += QStringLiteral("<div style='color:#656d76'>%1</div>").arg(esc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
html += "</pre>";
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash)
|
QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash)
|
||||||
{
|
{
|
||||||
const QString vault = QDir::cleanPath(vaultPath);
|
const QString vault = QDir::cleanPath(vaultPath);
|
||||||
|
|||||||
@@ -41,11 +41,6 @@ public:
|
|||||||
// Текущее содержимое файла на диске (для сравнения с версией). Пусто при ошибке/лимите 2 МБ.
|
// Текущее содержимое файла на диске (для сравнения с версией). Пусто при ошибке/лимите 2 МБ.
|
||||||
Q_INVOKABLE QString workdirContent(const QString &filePath) const;
|
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)
|
|
||||||
Q_INVOKABLE QString fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
|
||||||
|
|
||||||
// Восстановить файл из коммита (перезаписать и закоммитить). Возвращает "" при успехе или текст ошибки.
|
// Восстановить файл из коммита (перезаписать и закоммитить). Возвращает "" при успехе или текст ошибки.
|
||||||
Q_INVOKABLE QString restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash);
|
Q_INVOKABLE QString restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash);
|
||||||
|
|
||||||
|
|||||||
+21
-135
@@ -217,37 +217,6 @@ QString tintDiffHtml(const QString &inner, bool isDel)
|
|||||||
return QStringLiteral("<div style=\"background-color:rgba(34,197,94,0.45);border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
|
return QStringLiteral("<div style=\"background-color:rgba(34,197,94,0.45);border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").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)
|
QString fileHrefFor(const QString &path)
|
||||||
{
|
{
|
||||||
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
|
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
|
||||||
@@ -876,6 +845,17 @@ private:
|
|||||||
|
|
||||||
QString MdParser::render(const QString &markdown, const QString &vaultRoot,
|
QString MdParser::render(const QString &markdown, const QString &vaultRoot,
|
||||||
const QString &fileDir)
|
const QString &fileDir)
|
||||||
|
{
|
||||||
|
// Публичный вызов начинает нумерацию кодовых блоков заново (code_0, code_1, ...)
|
||||||
|
if (m) {
|
||||||
|
m->codeBlocks.clear();
|
||||||
|
m->codeCounter = 0;
|
||||||
|
}
|
||||||
|
return renderInner(markdown, vaultRoot, fileDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString MdParser::renderInner(const QString &markdown, const QString &vaultRoot,
|
||||||
|
const QString &fileDir)
|
||||||
{
|
{
|
||||||
cmark_gfm_core_extensions_ensure_registered();
|
cmark_gfm_core_extensions_ensure_registered();
|
||||||
cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT);
|
cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT);
|
||||||
@@ -888,10 +868,6 @@ QString MdParser::render(const QString &markdown, const QString &vaultRoot,
|
|||||||
cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
|
cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
|
||||||
cmark_node *doc = cmark_parser_finish(parser);
|
cmark_node *doc = cmark_parser_finish(parser);
|
||||||
|
|
||||||
if (m) {
|
|
||||||
m->codeBlocks.clear();
|
|
||||||
m->codeCounter = 0;
|
|
||||||
}
|
|
||||||
Renderer r(m, vaultRoot, fileDir);
|
Renderer r(m, vaultRoot, fileDir);
|
||||||
QString html = r.renderTree(doc);
|
QString html = r.renderTree(doc);
|
||||||
|
|
||||||
@@ -923,12 +899,7 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
if (start > lastPos) {
|
if (start > lastPos) {
|
||||||
QString htmlPart = markdown.mid(lastPos, start - lastPos);
|
QString htmlPart = markdown.mid(lastPos, start - lastPos);
|
||||||
if (!htmlPart.trimmed().isEmpty()) {
|
if (!htmlPart.trimmed().isEmpty()) {
|
||||||
// Сохраняем codeBlocks, т.к. render() их очищает
|
QString html = renderInner(htmlPart, vaultRoot, fileDir);
|
||||||
QHash<QString, QString> savedBlocks;
|
|
||||||
int savedCounter = 0;
|
|
||||||
if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; }
|
|
||||||
QString html = render(htmlPart, vaultRoot, fileDir);
|
|
||||||
if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; }
|
|
||||||
if (!html.trimmed().isEmpty()) {
|
if (!html.trimmed().isEmpty()) {
|
||||||
QVariantMap block;
|
QVariantMap block;
|
||||||
block["type"] = "html";
|
block["type"] = "html";
|
||||||
@@ -989,11 +960,7 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
}
|
}
|
||||||
// Если вообще не нашли блоки, но markdown не пустой — вернём как html
|
// Если вообще не нашли блоки, но markdown не пустой — вернём как html
|
||||||
if (blocks.isEmpty() && !markdown.trimmed().isEmpty()) {
|
if (blocks.isEmpty() && !markdown.trimmed().isEmpty()) {
|
||||||
QHash<QString, QString> savedBlocks;
|
QString html = renderInner(markdown, vaultRoot, fileDir);
|
||||||
int savedCounter = 0;
|
|
||||||
if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; }
|
|
||||||
QString html = render(markdown, vaultRoot, fileDir);
|
|
||||||
if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; }
|
|
||||||
QVariantMap block;
|
QVariantMap block;
|
||||||
block["type"] = "html";
|
block["type"] = "html";
|
||||||
block["html"] = html;
|
block["html"] = html;
|
||||||
@@ -1025,16 +992,6 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
if (oldMd.isEmpty() && newMd.isEmpty())
|
if (oldMd.isEmpty() && newMd.isEmpty())
|
||||||
return blocks;
|
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) {
|
auto appendHtmlBlock = [&](const QString &html, const char *change) {
|
||||||
QVariantMap block;
|
QVariantMap block;
|
||||||
block["type"] = "html";
|
block["type"] = "html";
|
||||||
@@ -1131,73 +1088,6 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
return true;
|
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<DiffOp> wops = diffMdBlocks(a, b);
|
|
||||||
struct Ph { QString token; QString word; bool isDel; };
|
|
||||||
QVector<Ph> 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("<span style=\"background-color:%1;border-radius:3px;padding:0 2px;\">%2</span>")
|
|
||||||
.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<DiffOp> ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd));
|
const QVector<DiffOp> ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd));
|
||||||
for (int k = 0; k < ops.size(); ++k) {
|
for (int k = 0; k < ops.size(); ++k) {
|
||||||
const DiffOp &op = ops[k];
|
const DiffOp &op = ops[k];
|
||||||
@@ -1206,16 +1096,12 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add");
|
const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add");
|
||||||
QString lang, code;
|
QString lang, code;
|
||||||
const bool isCode = isFenceBlock(op.text) && parseFenceBlock(op.text, &lang, &code);
|
const bool isCode = isFenceBlock(op.text) && parseFenceBlock(op.text, &lang, &code);
|
||||||
if (isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') {
|
// Изменённый код — один блок с построчным diff внутри; обычный текст —
|
||||||
if (isCode) {
|
// отдельными tinted-блоками (del, затем add), как стандартный diff
|
||||||
QString nlang, ncode;
|
if (isCode && isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') {
|
||||||
if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode)
|
QString nlang, ncode;
|
||||||
&& appendMergedCode(lang, code, nlang, ncode)) {
|
if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode)
|
||||||
++k;
|
&& appendMergedCode(lang, code, nlang, ncode)) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else if (!isFenceBlock(ops[k + 1].text)
|
|
||||||
&& appendMergedText(op.text, ops[k + 1].text)) {
|
|
||||||
++k;
|
++k;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1223,7 +1109,7 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
if (isCode) {
|
if (isCode) {
|
||||||
appendCodeBlock(lang, code, change, changed);
|
appendCodeBlock(lang, code, change, changed);
|
||||||
} else {
|
} else {
|
||||||
QString html = renderKeep(op.text);
|
QString html = renderInner(op.text, vaultRoot, fileDir);
|
||||||
if (html.trimmed().isEmpty())
|
if (html.trimmed().isEmpty())
|
||||||
continue;
|
continue;
|
||||||
if (changed)
|
if (changed)
|
||||||
@@ -1235,7 +1121,7 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
// Пустой результат при непустом вводе — отдать как один html-блок
|
// Пустой результат при непустом вводе — отдать как один html-блок
|
||||||
if (blocks.isEmpty() && (!oldMd.trimmed().isEmpty() || !newMd.trimmed().isEmpty())) {
|
if (blocks.isEmpty() && (!oldMd.trimmed().isEmpty() || !newMd.trimmed().isEmpty())) {
|
||||||
const QString src = !oldMd.trimmed().isEmpty() ? oldMd : newMd;
|
const QString src = !oldMd.trimmed().isEmpty() ? oldMd : newMd;
|
||||||
const QString html = renderKeep(src);
|
const QString html = renderInner(src, vaultRoot, fileDir);
|
||||||
if (!html.trimmed().isEmpty())
|
if (!html.trimmed().isEmpty())
|
||||||
appendHtmlBlock(html, "same");
|
appendHtmlBlock(html, "same");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,5 +42,11 @@ public:
|
|||||||
|
|
||||||
struct Impl;
|
struct Impl;
|
||||||
private:
|
private:
|
||||||
|
// Внутренний рендер без сброса codeBlocks/codeCounter (для parseBlocks*,
|
||||||
|
// которые сами управляют состоянием и вызывают его многократно)
|
||||||
|
QString renderInner(const QString &markdown,
|
||||||
|
const QString &vaultRoot,
|
||||||
|
const QString &fileDir);
|
||||||
|
|
||||||
Impl *m = nullptr;
|
Impl *m = nullptr;
|
||||||
};
|
};
|
||||||
@@ -66,14 +66,8 @@ int main(int argc, char *argv[])
|
|||||||
// content at
|
// content at
|
||||||
QString c1 = git.fileContentAt(vault, file, hash1);
|
QString c1 = git.fileContentAt(vault, file, hash1);
|
||||||
if(!c1.contains("Версия 1")) return fail("content v1", c1);
|
if(!c1.contains("Версия 1")) return fail("content v1", c1);
|
||||||
// diff с текущим (одинаково) должен быть "Нет изменений" или пусто?
|
// workdirContent читает текущее с диска
|
||||||
QString diffSame = git.fileDiffText(vault, file, hash1);
|
if(git.workdirContent(file) != "Версия 1\nСтрока A\n") return fail("workdirContent", git.workdirContent(file));
|
||||||
if(diffSame != "Нет изменений" && !diffSame.isEmpty()) {
|
|
||||||
// если файл не менялся, diff должен быть "Нет изменений"
|
|
||||||
// Но мы ещё не меняли, так что должно быть нет изменений
|
|
||||||
if(!diffSame.contains("Нет изменений"))
|
|
||||||
return fail("diff same should be нет изменений", diffSame);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Изменяем файл → v2
|
// Изменяем файл → v2
|
||||||
QThread::msleep(1100); // чтобы время коммита отличалось
|
QThread::msleep(1100); // чтобы время коммита отличалось
|
||||||
@@ -95,12 +89,6 @@ int main(int argc, char *argv[])
|
|||||||
// content at hash1 всё ещё v1
|
// content at hash1 всё ещё v1
|
||||||
QString c1again = git.fileContentAt(vault, file, hash1);
|
QString c1again = git.fileContentAt(vault, file, hash1);
|
||||||
if(!c1again.contains("Версия 1") || c1again.contains("Строка B")) return fail("c1again", c1again);
|
if(!c1again.contains("Версия 1") || c1again.contains("Строка B")) return fail("c1again", c1again);
|
||||||
// diff hash1 vs current (v2): должен показать + строка B и -Версия1 +Версия2
|
|
||||||
QString diff = git.fileDiffText(vault, file, hash1);
|
|
||||||
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("34,197,94") || !diffHtml.contains("244,63,94")) return fail("diffHtml colors", diffHtml.left(500));
|
|
||||||
|
|
||||||
// forward/backward: добавляем ещё файл other.md, коммитим, история note.md не должна вырасти
|
// forward/backward: добавляем ещё файл other.md, коммитим, история note.md не должна вырасти
|
||||||
QString other = vault+"/other.md";
|
QString other = vault+"/other.md";
|
||||||
@@ -140,18 +128,10 @@ int main(int argc, char *argv[])
|
|||||||
if(hist5.size()!=5) return fail("hist 5", QString::number(hist5.size()));
|
if(hist5.size()!=5) return fail("hist 5", QString::number(hist5.size()));
|
||||||
if(hist5.first().toMap().value("message").toString()!="v3") return fail("top v3");
|
if(hist5.first().toMap().value("message").toString()!="v3") return fail("top v3");
|
||||||
|
|
||||||
// Проверка что diff между v1 и текущим (v3) показывает изменения
|
|
||||||
QString diffV1Now = git.fileDiffText(vault, file, hash1);
|
|
||||||
if(!diffV1Now.contains("Версия 1") || !diffV1Now.contains("Версия 3")) return fail("diff v1->v3", diffV1Now);
|
|
||||||
|
|
||||||
// Проверка несуществующего коммита
|
// Проверка несуществующего коммита
|
||||||
QString badContent = git.fileContentAt(vault, file, "0000000000000000000000000000000000000000");
|
QString badContent = git.fileContentAt(vault, file, "0000000000000000000000000000000000000000");
|
||||||
if(!badContent.isEmpty()) return fail("bad hash should return empty");
|
if(!badContent.isEmpty()) return fail("bad hash should return empty");
|
||||||
|
|
||||||
QString badDiff = git.fileDiffText(vault, file, "0000000000000000000000000000000000000000");
|
|
||||||
// должен либо вернуть ошибку diff, либо содержать old empty
|
|
||||||
// не проверяем строго
|
|
||||||
|
|
||||||
QString badRestore = git.restoreFile(vault, file, "0000000000000000000000000000000000000000");
|
QString badRestore = git.restoreFile(vault, file, "0000000000000000000000000000000000000000");
|
||||||
if(badRestore.isEmpty()) return fail("bad restore should fail");
|
if(badRestore.isEmpty()) return fail("bad restore should fail");
|
||||||
|
|
||||||
|
|||||||
+9
-20
@@ -184,32 +184,21 @@ int main(int argc, char *argv[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseBlocksWithDiff: правка в обычном тексте — ОДИН блок со словным diff внутри
|
// parseBlocksWithDiff: правка в обычном тексте — два отдельных tinted-блока
|
||||||
|
// (del, затем add), как стандартный diff; словного уровня нет
|
||||||
{
|
{
|
||||||
const QString oldMd = "# T\n\nOld words here\n";
|
const QString oldMd = "# T\n\nOld words here\n";
|
||||||
const QString newMd = "# T\n\nNew words here\n";
|
const QString newMd = "# T\n\nNew words here\n";
|
||||||
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||||
if (d.size() != 2) return fail("text merge размер", QString::number(d.size()));
|
if (d.size() != 3) return fail("text diff размер", QString::number(d.size()));
|
||||||
if (d[0].toMap().value("change").toString() != "same") return fail("text merge[0] same");
|
if (d[0].toMap().value("change").toString() != "same") return fail("text diff[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"
|
if (d[1].toMap().value("change").toString() != "del"
|
||||||
|| d[2].toMap().value("change").toString() != "add")
|
|| d[2].toMap().value("change").toString() != "add")
|
||||||
return fail("link fallback del/add");
|
return fail("text diff del/add");
|
||||||
|
if (!d[1].toMap().value("html").toString().contains("244,63,94"))
|
||||||
|
return fail("text del без розового");
|
||||||
|
if (!d[2].toMap().value("html").toString().contains("34,197,94"))
|
||||||
|
return fail("text add без зелёного");
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри
|
// parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри
|
||||||
|
|||||||
Reference in New Issue
Block a user