diff --git a/CMakeLists.txt b/CMakeLists.txt index d01efe0..a987179 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,7 +23,7 @@ message(STATUS "OpenSSL: ${OPENSSL_VERSION}") # Qt 6 find_package(Qt6 6.4 REQUIRED COMPONENTS Quick QuickControls2 QuickTemplates2 QuickLayouts - Sql Network Pdf PrintSupport Svg) + Sql Network Pdf PrintSupport Svg Concurrent) qt_standard_project_setup(REQUIRES 6.4) @@ -81,6 +81,7 @@ target_link_libraries(flora_core PUBLIC Qt6::Pdf Qt6::PrintSupport Qt6::Svg + Qt6::Concurrent cmark::cmark ) if(TARGET cmark::cmark_extensions) diff --git a/qml/dialogs/HistoryDialog.qml b/qml/dialogs/HistoryDialog.qml index 0df6c93..691ff18 100644 --- a/qml/dialogs/HistoryDialog.qml +++ b/qml/dialogs/HistoryDialog.qml @@ -32,6 +32,9 @@ Dialog { property string currentContent: "" property string emptyMessage: qsTr("Нет версии для просмотра") property bool busy: false + // Пагинация: подгружаем порциями, чтобы revwalk не бежал по всей истории сразу + property int historyLimit: 60 + property bool hasMore: false function fileDirOf(p) { const i = p.lastIndexOf("/") @@ -66,13 +69,23 @@ Dialog { return } busy = true - // берём историю - const hist = gitManager.fileHistory(vaultPath, filePath) - historyModel = hist - listView.model = hist - if (hist.length>0) { - selectedIndex = 0 - selectedHash = hist[0].hash + // берём историю порцией limit+1: лишний элемент — признак «есть ещё» + const keepHash = selectedHash + const hist = gitManager.fileHistory(vaultPath, filePath, historyLimit + 1) + hasMore = hist.length > historyLimit + const shown = hasMore ? hist.slice(0, historyLimit) : hist + historyModel = shown + listView.model = shown + if (shown.length>0) { + // сохраняем выбор при «Показать ещё»/обновлении + let idx = 0 + if (keepHash.length > 0) { + for (let i = 0; i < shown.length; ++i) { + if (shown[i].hash === keepHash) { idx = i; break } + } + } + selectedIndex = idx + selectedHash = shown[idx].hash updateDiff() } else { selectedIndex = -1 @@ -93,13 +106,15 @@ Dialog { if (selectedHash.length===0) { versionContent = "" currentContent = "" + busy = false return } // свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге currentContent = gitManager.workdirContent(filePath) // содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре», - // с подсветкой отличий от текущего файла прямо там + // с подсветкой отличий от текущего файла прямо там (считается в рабочем потоке) versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash) + busy = currentContent.length > 0 && versionContent.length > 0 } onOpened: reload() @@ -280,6 +295,12 @@ Dialog { font.family: "Menlo, monospace" } } + Button { + visible: root.hasMore + Layout.fillWidth: true + text: qsTr("Показать ещё") + onClicked: { root.historyLimit += 60; root.reload() } + } } } @@ -302,16 +323,17 @@ Dialog { 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: root.emptyMessage - onLinkActivated: (href) => root.handleHistoryLink(href) - } + MarkdownView { + id: versionPreview + width: parent.width + markdown: root.versionContent + compareMarkdown: root.currentContent + vaultRoot: root.vaultPath + fileDir: root.fileDirOf(root.filePath) + emptyText: root.emptyMessage + onLinkActivated: (href) => root.handleHistoryLink(href) + onDiffApplied: root.busy = false + } } } } @@ -355,7 +377,10 @@ Dialog { text: qsTr("Восстановить эту версию") enabled: root.selectedHash.length>0 highlighted: true - onClicked: confirmRestore.open() + onClicked: { + confirmRestore.willDelete = !gitManager.fileExistedAt(root.vaultPath, root.filePath, root.selectedHash) + confirmRestore.open() + } } } } @@ -363,6 +388,7 @@ Dialog { // Подтверждение восстановления Dialog { id: confirmRestore + property bool willDelete: false title: qsTr("Восстановить версию?") modal: true anchors.centerIn: parent @@ -373,10 +399,12 @@ Dialog { anchors.fill: parent spacing: 12 Label { - text: qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.") + text: confirmRestore.willDelete + ? qsTr("ВНИМАНИЕ: в выбранной версии этого файла не было — файл будет УДАЛЁН (это тоже сохранится как новая версия, отменить можно через историю).") + : qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.") wrapMode: Text.Wrap Layout.fillWidth: true - color: Theme.text + color: confirmRestore.willDelete ? Theme.errorText : Theme.text } Label { text: qsTr("Версия: %1 (%2)").arg(root.selectedHash.substring(0,7)).arg(root.selectedIndex>=0 && historyModel.length>root.selectedIndex ? historyModel[root.selectedIndex].timeStr : "") @@ -389,7 +417,7 @@ Dialog { Item { Layout.fillWidth: true } Button { text: qsTr("Отмена"); onClicked: confirmRestore.close() } Button { - text: qsTr("Восстановить") + text: confirmRestore.willDelete ? qsTr("Удалить файл") : qsTr("Восстановить") highlighted: true onClicked: { confirmRestore.close() diff --git a/qml/panels/MarkdownView.qml b/qml/panels/MarkdownView.qml index cdf492d..0dd47ef 100644 --- a/qml/panels/MarkdownView.qml +++ b/qml/panels/MarkdownView.qml @@ -10,19 +10,24 @@ Item { property string fileDir: "" property string emptyText: qsTr("Пустой файл — начните писать в режиме редактирования") // Если задан — markdown считается старой версией и подсвечиваются отличия от него - // (блоки del — только в markdown, add — только в compareMarkdown) + // (блоки del — только в markdown, add — только в compareMarkdown). + // Сравнение считается в рабочем потоке, чтобы UI не висел на больших файлах. property string compareMarkdown: "" + property int _diffReq: -1 implicitHeight: column.implicitHeight + 24 signal linkActivated(string href) + signal diffApplied() // Для обновления function update() { + if (compareMarkdown.length > 0) { + _diffReq = mdParser.parseBlocksWithDiffAsync(markdown, compareMarkdown, vaultRoot, fileDir) + return + } // Триггерим пересборку блоков blocksModel.clear() - const blocks = compareMarkdown.length > 0 - ? mdParser.parseBlocksWithDiff(markdown, compareMarkdown, vaultRoot, fileDir) - : mdParser.parseBlocks(markdown, vaultRoot, fileDir) + const blocks = mdParser.parseBlocks(markdown, vaultRoot, fileDir) for (let i = 0; i < blocks.length; i++) { blocksModel.append(blocks[i]) } @@ -30,6 +35,19 @@ Item { // contentItem.contentY = 0 } + Connections { + target: mdParser + function onBlocksReady(reqId, blocks) { + if (reqId !== root._diffReq) + return // устарел — уже запрошен более свежий + blocksModel.clear() + for (let i = 0; i < blocks.length; i++) { + blocksModel.append(blocks[i]) + } + root.diffApplied() + } + } + onMarkdownChanged: update() onCompareMarkdownChanged: update() onVaultRootChanged: update() diff --git a/src/core/DocumentManager.cpp b/src/core/DocumentManager.cpp index 5f08d35..399a6eb 100644 --- a/src/core/DocumentManager.cpp +++ b/src/core/DocumentManager.cpp @@ -127,8 +127,15 @@ int DocumentManager::openDocument(const QString &path) QString DocumentManager::readFile(const QString &path, bool *ok) const { + return readTextFile(path, 10LL * 1024 * 1024, ok); +} + +QString DocumentManager::readTextFile(const QString &path, qint64 maxBytes, bool *ok) +{ + if (maxBytes <= 0) + maxBytes = 10LL * 1024 * 1024; QFileInfo fi(path); - if (fi.exists() && fi.size() > 10 * 1024 * 1024) { + if (fi.exists() && fi.size() > maxBytes) { if (ok) *ok = false; return QString(); } @@ -137,9 +144,9 @@ QString DocumentManager::readFile(const QString &path, bool *ok) const if (ok) *ok = false; return QString(); } - // Ограничиваем чтение 10 МБ для защиты от OOM - const QByteArray bytes = f.read(10 * 1024 * 1024 + 1); - if (bytes.size() > 10 * 1024 * 1024) { + // Ограничиваем чтение для защиты от OOM + const QByteArray bytes = f.read(maxBytes + 1); + if (bytes.size() > maxBytes) { if (ok) *ok = false; return QString(); } diff --git a/src/core/DocumentManager.h b/src/core/DocumentManager.h index f8163d6..d1c3073 100644 --- a/src/core/DocumentManager.h +++ b/src/core/DocumentManager.h @@ -79,6 +79,11 @@ private: void ensureAutosave(); QString readFile(const QString &path, bool *ok) const; +public: + // Общее чтение текстового файла с лимитом (защита от OOM). Используется и GitManager. + static QString readTextFile(const QString &path, qint64 maxBytes = 10LL * 1024 * 1024, + bool *ok = nullptr); + QList m_docs; int m_active = -1; int m_secondary = -1; diff --git a/src/git/GitManager.cpp b/src/git/GitManager.cpp index 07e0956..ba65493 100644 --- a/src/git/GitManager.cpp +++ b/src/git/GitManager.cpp @@ -1,5 +1,7 @@ #include "git/GitManager.h" +#include "core/DocumentManager.h" + #include #include #include @@ -188,29 +190,13 @@ QString GitManager::fileGitStatus(const QString &vaultPath, const QString &fileP if (vaultPath.isEmpty() || filePath.isEmpty()) return QStringLiteral("—"); const QString vault = QDir::cleanPath(vaultPath); - const QString file = QDir::cleanPath(filePath); - if (!file.startsWith(vault + '/') && file != vault) + const QString rel = relativePath(vaultPath, filePath); + if (rel.isEmpty()) return QStringLiteral("—"); git_repository *repo = nullptr; if (git_repository_open(&repo, vault.toUtf8().constData()) < 0) return QStringLiteral("—"); - // относительный путь внутри репозитория - QString rel = file; - if (rel == vault) { - git_repository_free(repo); - return QStringLiteral("—"); - } - if (rel.startsWith(vault)) { - rel = rel.mid(vault.length()); - while (rel.startsWith("/") || rel.startsWith("\\")) - rel.remove(0, 1); - } - if (rel.isEmpty()) { - git_repository_free(repo); - return QStringLiteral("—"); - } - unsigned int status = 0; const int rc = git_status_file(&status, repo, rel.toUtf8().constData()); git_repository_free(repo); @@ -252,6 +238,73 @@ QString GitManager::fileGitStatusShort(const QString &vaultPath, const QString & return s; } +bool GitManager::commitFromIndex(git_repository *repo, git_index *index, const QString &message) +{ + // Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов, + // что тоже нужно коммитить. Проверяем равенство дерева ниже. + + // Создаём дерево + git_oid treeId{}; + git_tree *tree = nullptr; + if (git_index_write_tree(&treeId, index) < 0 + || git_tree_lookup(&tree, repo, &treeId) < 0) { + setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr())); + return false; + } + + // Родитель: head, если коммиты уже есть + git_reference *headRef = nullptr; + git_commit *parentCommit = nullptr; + int nParents = 0; + if (git_repository_head(&headRef, repo) == 0) { + git_object *obj = nullptr; + if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) { + parentCommit = reinterpret_cast(obj); + nParents = 1; + } + if (headRef) git_reference_free(headRef); + } + + // Если дерево не изменилось относительно родителя — коммитить нечего. + if (parentCommit) { + const git_oid *prevTree = git_commit_tree_id(parentCommit); + if (prevTree && git_oid_equal(prevTree, &treeId)) { + git_commit_free(parentCommit); + git_tree_free(tree); + setLastError(QStringLiteral("Нет изменений для коммита")); + return false; + } + } + const git_commit *parents[1] = {parentCommit}; + + // Подпись из конфига репозитория + git_signature *author = nullptr; + if (git_signature_default(&author, repo) < 0) { + git_commit_free(parentCommit); + git_tree_free(tree); + setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr())); + return false; + } + + git_oid commitId{}; + const int crc = git_commit_create(&commitId, repo, "HEAD", + author, author, "UTF-8", + message.toUtf8().constData(), + tree, nParents, parents); + + git_signature_free(author); + if (parentCommit) git_commit_free(parentCommit); + git_tree_free(tree); + + if (crc < 0) { + setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr())); + return false; + } + + setLastError(QString()); + return true; +} + bool GitManager::commitAll(const QString &path, const QString &message) { const QString cleanPath = QDir::cleanPath(path); @@ -268,78 +321,74 @@ bool GitManager::commitAll(const QString &path, const QString &message) // Добавляем все файлы, включая новые и удаления (с учётом .gitignore) git_index_add_all(info.index, nullptr, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr); - const int wrc = git_index_write(info.index); - if (wrc < 0) { + if (git_index_write(info.index) < 0) { setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr())); return false; } - // Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов, - // что тоже нужно коммитить. Проверяем равенство дерева ниже. + return commitFromIndex(info.repo, info.index, message); +} - // Создаём дерево - git_tree *tree = nullptr; - if (git_index_write_tree(&info.treeId, info.index) < 0 - || git_tree_lookup(&tree, info.repo, &info.treeId) < 0) { - setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr())); +bool GitManager::commitFile(const QString &vaultPath, const QString &filePath, const QString &message) +{ + const QString vault = QDir::cleanPath(vaultPath); + const QString rel = relativePath(vaultPath, filePath); + if (vault.isEmpty() || rel.isEmpty()) { + setLastError(QStringLiteral("Неверные пути")); + return false; + } + CommitInfo info; + if (git_repository_open(&info.repo, vault.toUtf8().constData()) < 0) { + setLastError(QStringLiteral("Хранилище не является git-репозиторием")); return false; } - // Родитель: head, если коммиты уже есть - git_reference *headRef = nullptr; - git_commit *parentCommit = nullptr; - int nParents = 0; - if (git_repository_head(&headRef, info.repo) == 0) { - git_object *obj = nullptr; - if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) { - parentCommit = reinterpret_cast(obj); - nParents = 1; - } - if (headRef) git_reference_free(headRef); - } - - // Если дерево не изменилось относительно родителя — коммитить нечего. - if (parentCommit) { - const git_oid *prevTree = git_commit_tree_id(parentCommit); - if (prevTree && git_oid_equal(prevTree, &info.treeId)) { - git_commit_free(parentCommit); - git_tree_free(tree); - setLastError(QStringLiteral("Нет изменений для коммита")); - return false; - } - } - const git_commit *parents[1] = {parentCommit}; - - // Подпись из конфига репозитория - git_signature *author = nullptr; - if (git_signature_default(&author, info.repo) < 0) { - git_commit_free(parentCommit); - git_tree_free(tree); - setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr())); - return false; - } - info.author = author; - - const int crc = git_commit_create(&info.commitId, info.repo, "HEAD", - author, author, "UTF-8", - message.toUtf8().constData(), - tree, nParents, parents); - - if (parentCommit) git_commit_free(parentCommit); - git_tree_free(tree); - - if (crc < 0) { - setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr())); + if (git_repository_index(&info.index, info.repo) < 0) { + setLastError(QStringLiteral("Не удалось открыть индекс: %1").arg(gitErr())); return false; } - setLastError(QString()); - return true; + // Точечно: существующий файл — в индекс, удалённый — из индекса + const QByteArray relBytes = rel.toUtf8(); + int rc; + if (QFile::exists(QDir(vault).filePath(rel))) + rc = git_index_add_bypath(info.index, relBytes.constData()); + else + rc = git_index_remove_bypath(info.index, relBytes.constData()); + if (rc < 0) { + setLastError(QStringLiteral("Не удалось добавить файл в индекс: %1").arg(gitErr())); + return false; + } + if (git_index_write(info.index) < 0) { + setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr())); + return false; + } + + return commitFromIndex(info.repo, info.index, message); +} + +QString GitManager::headOid(git_repository *repo) const +{ + git_oid oid; + if (git_reference_name_to_id(&oid, repo, "HEAD") < 0) + return QString(); + char buf[41] = {0}; + git_oid_tostr(buf, sizeof(buf), &oid); + return QString::fromUtf8(buf); } QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &filePath) const +{ + return fileHistory(vaultPath, filePath, 500); +} + +QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &filePath, + int limit) const { QVariantList out; + if (limit <= 0) + limit = 500; + limit = qMin(limit, 2000); const QString vault = QDir::cleanPath(vaultPath); const QString rel = relativePath(vaultPath, filePath); if (vault.isEmpty() || rel.isEmpty()) @@ -351,6 +400,13 @@ QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &fi git_repository_free(repo); return out; } + // История меняется только с HEAD — при том же HEAD отдаём кэш + const QString head = headOid(repo); + const QString key = vault + QLatin1Char('|') + rel + QLatin1Char('|') + head; + if (!head.isEmpty() && key == m_histKey && m_histCache.size() >= limit) { + git_repository_free(repo); + return m_histCache.mid(0, limit); + } git_revwalk *walk = nullptr; if (git_revwalk_new(&walk, repo) < 0) { git_repository_free(repo); @@ -361,7 +417,7 @@ QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &fi git_oid oid; int iter = 0; - const int maxIter = 500; // ограничение для скорости + const int maxIter = limit; // ограничение для скорости while (git_revwalk_next(&oid, walk) == 0 && iter < maxIter) { ++iter; git_commit *commit = nullptr; @@ -444,6 +500,10 @@ QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &fi } git_revwalk_free(walk); git_repository_free(repo); + if (!head.isEmpty()) { + m_histKey = key; + m_histCache = out; + } return out; } @@ -488,6 +548,37 @@ QString GitManager::fileContentAt(const QString &vaultPath, const QString &fileP return result; } +bool GitManager::fileExistedAt(const QString &vaultPath, const QString &filePath, + const QString &commitHash) const +{ + const QString vault = QDir::cleanPath(vaultPath); + const QString rel = relativePath(vaultPath, filePath); + if (vault.isEmpty() || rel.isEmpty() || commitHash.isEmpty()) + return false; + git_repository *repo = nullptr; + if (git_repository_open(&repo, vault.toUtf8().constData()) < 0) + return false; + git_oid oid; + bool existed = false; + if (git_oid_fromstr(&oid, commitHash.toUtf8().constData()) == 0) { + git_commit *commit = nullptr; + if (git_commit_lookup(&commit, repo, &oid) == 0) { + git_tree *tree = nullptr; + if (git_commit_tree(&tree, commit) == 0) { + git_tree_entry *entry = nullptr; + if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) { + existed = true; + git_tree_entry_free(entry); + } + git_tree_free(tree); + } + git_commit_free(commit); + } + } + git_repository_free(repo); + return existed; +} + QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash) { const QString vault = QDir::cleanPath(vaultPath); @@ -528,10 +619,7 @@ QString GitManager::restoreFile(const QString &vaultPath, const QString &filePat 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()); + return DocumentManager::readTextFile(filePath, 2LL * 1024 * 1024); } QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const @@ -568,8 +656,9 @@ bool GitManager::autoCommit(const QString &vaultPath, const QString &filePath) const QString msg = fileName.isEmpty() ? QStringLiteral("Автосохранение") : QStringLiteral("Автосохранение: %1").arg(fileName); - // commitAll вернёт false если нет изменений — это нормально - bool ok = commitAll(vaultPath, msg); + // Точечный коммит одного файла (а не всего хранилища) — сообщение честное. + // commitFile вернёт false если нет изменений — это нормально + bool ok = commitFile(vaultPath, filePath, msg); if (!ok && lastError() == QStringLiteral("Нет изменений для коммита")) { setLastError(QString()); return true; // считаем успехом diff --git a/src/git/GitManager.h b/src/git/GitManager.h index a1dee87..4cd7939 100644 --- a/src/git/GitManager.h +++ b/src/git/GitManager.h @@ -22,6 +22,10 @@ public: // Коммитит все изменения в хранилище (рабочее дерево). Возвращает true/false. Q_INVOKABLE bool commitAll(const QString &path, const QString &message); + // Коммитит только один файл (добавление/изменение/удаление). Возвращает true/false. + Q_INVOKABLE bool commitFile(const QString &vaultPath, const QString &filePath, + const QString &message); + // Есть ли в хранилище хотя бы один коммит. Q_INVOKABLE bool hasCommits(const QString &path) const; @@ -34,10 +38,18 @@ public: // История файла: список коммитов где файл менялся. Сортировка новые сверху. // Каждый элемент: {hash, shortHash, message, author, time (QDateTime), timeStr, timeAgo} Q_INVOKABLE QVariantList fileHistory(const QString &vaultPath, const QString &filePath) const; + // То же, но не больше limit свежих (limit<=0 — до 500, как раньше). + // Для кнопки «Показать ещё»: запросить limit+1, hasMore = (размер > limit). + Q_INVOKABLE QVariantList fileHistory(const QString &vaultPath, const QString &filePath, + int limit) const; // Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал. Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const; + // Существовал ли файл в коммите (отличает «пустой файл» от «файла не было»). + Q_INVOKABLE bool fileExistedAt(const QString &vaultPath, const QString &filePath, + const QString &commitHash) const; + // Текущее содержимое файла на диске (для сравнения с версией). Пусто при ошибке/лимите 2 МБ. Q_INVOKABLE QString workdirContent(const QString &filePath) const; @@ -66,7 +78,15 @@ private: void setLastError(const QString &msg); QString relativePath(const QString &vaultPath, const QString &filePath) const; void ensureGitignore(const QString &vaultPath); + // OID текущего HEAD ("" если нет). Для инвалидации кэша истории. + QString headOid(git_repository *repo) const; + // Создаёт коммит из уже записанного индекса (общее для commitAll/commitFile). + bool commitFromIndex(git_repository *repo, git_index *index, const QString &message); QString m_lastError; static bool s_gitInitialized; + + // Кэш истории: ключ vault|rel|headOid. История меняется только с HEAD. + mutable QString m_histKey; + mutable QVariantList m_histCache; }; \ No newline at end of file diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp index 0afa8e3..0d0f329 100644 --- a/src/markdown/MdParser.cpp +++ b/src/markdown/MdParser.cpp @@ -20,12 +20,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include using KSyntaxHighlighting::AbstractHighlighter; using KSyntaxHighlighting::Definition; @@ -854,8 +856,10 @@ QString MdParser::render(const QString &markdown, const QString &vaultRoot, return renderInner(markdown, vaultRoot, fileDir); } -QString MdParser::renderInner(const QString &markdown, const QString &vaultRoot, - const QString &fileDir) +// Файлово-статический рендер с явным Impl: безопасен для рабочего потока +// (свой парсер cmark на вызов, никакой разделяемой мутации кроме переданного impl). +static QString renderWithImpl(MdParser::Impl *impl, const QString &markdown, + const QString &vaultRoot, const QString &fileDir) { cmark_gfm_core_extensions_ensure_registered(); cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT); @@ -868,7 +872,7 @@ QString MdParser::renderInner(const QString &markdown, const QString &vaultRoot, cmark_parser_feed(parser, utf8.constData(), static_cast(utf8.size())); cmark_node *doc = cmark_parser_finish(parser); - Renderer r(m, vaultRoot, fileDir); + Renderer r(impl, vaultRoot, fileDir); QString html = r.renderTree(doc); cmark_node_free(doc); @@ -876,6 +880,12 @@ QString MdParser::renderInner(const QString &markdown, const QString &vaultRoot, return html; } +QString MdParser::renderInner(const QString &markdown, const QString &vaultRoot, + const QString &fileDir) +{ + return renderWithImpl(m, markdown, vaultRoot, fileDir); +} + QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vaultRoot, const QString &fileDir) { QVariantList blocks; @@ -981,13 +991,16 @@ QString MdParser::highlightedCode(const QString &code, const QString &language) return hl.highlight(code, lang, true); } -QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &newMd, - const QString &vaultRoot, const QString &fileDir) +// Потокобезопасное ядро diff-блоков: работает с явно переданным Impl +// (в главном потоке — m, в рабочем — локальный), состояние только в нём. +static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd, + const QString &newMd, const QString &vaultRoot, + const QString &fileDir) { QVariantList blocks; - if (m) { - m->codeBlocks.clear(); - m->codeCounter = 0; + if (impl) { + impl->codeBlocks.clear(); + impl->codeCounter = 0; } if (oldMd.isEmpty() && newMd.isEmpty()) return blocks; @@ -1006,13 +1019,13 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & 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); + if (impl) { + codeId = QStringLiteral("code_%1").arg(impl->codeCounter++); + impl->codeBlocks.insert(codeId, code); } - CodeHighlighter hl(m ? m->m_repo : nullptr); - QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true) - : plainToRich(code); + CodeHighlighter hl(impl ? impl->m_repo : nullptr); + QString highlighted = (impl && impl->m_repo) ? hl.highlight(code, lang, true) + : plainToRich(code); if (tint) highlighted = tintDiffHtml(highlighted, change[0] == 'd'); QVariantMap block; @@ -1030,11 +1043,11 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & // подсветкой внутри — изменения видны там, где сделаны. Копируется новое. 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); + CodeHighlighter hl(impl ? impl->m_repo : nullptr); + const QString oldHl = (impl && impl->m_repo) ? hl.highlight(oldCode, oldLang, true) + : plainToRich(oldCode); + const QString newHl = (impl && impl->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("
")); @@ -1072,9 +1085,9 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & flushPending(); const QString lang = !newLang.isEmpty() ? newLang : oldLang; QString codeId; - if (m) { - codeId = QStringLiteral("code_%1").arg(m->codeCounter++); - m->codeBlocks.insert(codeId, newCode); + if (impl) { + codeId = QStringLiteral("code_%1").arg(impl->codeCounter++); + impl->codeBlocks.insert(codeId, newCode); } QVariantMap block; block["type"] = "code"; @@ -1109,7 +1122,7 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & if (isCode) { appendCodeBlock(lang, code, change, changed); } else { - QString html = renderInner(op.text, vaultRoot, fileDir); + QString html = renderWithImpl(impl, op.text, vaultRoot, fileDir); if (html.trimmed().isEmpty()) continue; if (changed) @@ -1121,9 +1134,54 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & // Пустой результат при непустом вводе — отдать как один html-блок if (blocks.isEmpty() && (!oldMd.trimmed().isEmpty() || !newMd.trimmed().isEmpty())) { const QString src = !oldMd.trimmed().isEmpty() ? oldMd : newMd; - const QString html = renderInner(src, vaultRoot, fileDir); + const QString html = renderWithImpl(impl, src, vaultRoot, fileDir); if (!html.trimmed().isEmpty()) appendHtmlBlock(html, "same"); } return blocks; +} + +QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &newMd, + const QString &vaultRoot, const QString &fileDir) +{ + if (m) { + m->codeBlocks.clear(); + m->codeCounter = 0; + } + return buildDiffBlocks(m, oldMd, newMd, vaultRoot, fileDir); +} + +int MdParser::parseBlocksWithDiffAsync(const QString &oldMd, const QString &newMd, + const QString &vaultRoot, const QString &fileDir) +{ + const int id = ++m_asyncId; + QFutureWatcher *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher, id]() { + QVariantList blocks = watcher->result(); + watcher->deleteLater(); + if (id != m_asyncId || !m) + return; // устарел — пользователь уже смотрит другую версию + if (m->codeBlocks.size() > 2000) { + m->codeBlocks.clear(); // защита от бесконечного роста между полными перерисовками + m->codeCounter = 0; + } + // Коды регистрируем здесь, в главном потоке, свежими id + for (auto &v : blocks) { + QVariantMap bm = v.toMap(); + if (bm.value("type").toString() == QLatin1String("code")) { + const QString newId = QStringLiteral("code_%1").arg(m->codeCounter++); + m->codeBlocks.insert(newId, bm.value("codeText").toString()); + bm["codeId"] = newId; + v = bm; + } + } + emit blocksReady(id, blocks); + }); + watcher->setFuture(QtConcurrent::run([oldMd, newMd, vaultRoot, fileDir]() { + MdParser::Impl local; // свой Repository подсветки — главный поток не трогаем + if (!local.m_repo) + return QVariantList(); + return buildDiffBlocks(&local, oldMd, newMd, vaultRoot, fileDir); + })); + return id; } \ No newline at end of file diff --git a/src/markdown/MdParser.h b/src/markdown/MdParser.h index edac4e3..4a4f855 100644 --- a/src/markdown/MdParser.h +++ b/src/markdown/MdParser.h @@ -36,10 +36,22 @@ public: const QString &vaultRoot, const QString &fileDir); + // То же, но в рабочем потоке (UI не виснет на больших файлах). + // Возвращает requestId; результат приходит сигналом blocksReady (устаревшие id игнорить). + // Регистрация кодов для копирования выполняется в главном потоке при приёме. + Q_INVOKABLE int parseBlocksWithDiffAsync(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; +signals: + void blocksReady(int requestId, const QVariantList &blocks); + +public: struct Impl; private: // Внутренний рендер без сброса codeBlocks/codeCounter (для parseBlocks*, @@ -49,4 +61,5 @@ private: const QString &fileDir); Impl *m = nullptr; + int m_asyncId = 0; }; \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e1f1aa4..d28e8a4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,8 @@ add_test(NAME flora_smoke COMMAND flora_smoke) add_executable(flora_git_test git_test.cpp ../src/git/GitManager.cpp ../src/git/GitManager.h + ../src/core/DocumentManager.cpp + ../src/core/DocumentManager.h ) target_include_directories(flora_git_test PRIVATE ../src) target_compile_definitions(flora_git_test PRIVATE CMARK_GFM_STATIC_DEFINE) @@ -27,6 +29,8 @@ add_executable(flora_vault_test vault_test.cpp ../src/app/VaultManager.h ../src/core/SettingsStore.cpp ../src/core/SettingsStore.h + ../src/core/DocumentManager.cpp + ../src/core/DocumentManager.h ../src/git/GitManager.cpp ../src/git/GitManager.h ) @@ -68,6 +72,7 @@ target_compile_definitions(flora_mdparser_test PRIVATE target_link_libraries(flora_mdparser_test PRIVATE Qt6::Core Qt6::Gui + Qt6::Concurrent cmark::cmark cmark::cmark_extensions ) @@ -119,7 +124,7 @@ add_executable(flora_extended_test extended_test.cpp ) target_include_directories(flora_extended_test PRIVATE ../src) target_compile_definitions(flora_extended_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE) -target_link_libraries(flora_extended_test PRIVATE Qt6::Core Qt6::Gui cmark::cmark cmark::cmark_extensions Flora_libgit2_static) +target_link_libraries(flora_extended_test PRIVATE Qt6::Core Qt6::Gui Qt6::Concurrent cmark::cmark cmark::cmark_extensions Flora_libgit2_static) if(TARGET KF6SyntaxHighlighting) target_link_libraries(flora_extended_test PRIVATE KF6SyntaxHighlighting) endif() @@ -147,7 +152,7 @@ add_executable(flora_integration_test integration_test.cpp ) target_include_directories(flora_integration_test PRIVATE ../src) target_compile_definitions(flora_integration_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE) -target_link_libraries(flora_integration_test PRIVATE Qt6::Core Qt6::Gui cmark::cmark cmark::cmark_extensions Flora_libgit2_static) +target_link_libraries(flora_integration_test PRIVATE Qt6::Core Qt6::Gui Qt6::Concurrent cmark::cmark cmark::cmark_extensions Flora_libgit2_static) if(TARGET KF6SyntaxHighlighting) target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting) endif() @@ -157,6 +162,8 @@ add_test(NAME flora_integration_test COMMAND flora_integration_test) add_executable(flora_git_history_test git_history_test.cpp ../src/git/GitManager.cpp ../src/git/GitManager.h + ../src/core/DocumentManager.cpp + ../src/core/DocumentManager.h ) target_include_directories(flora_git_history_test PRIVATE ../src) target_link_libraries(flora_git_history_test PRIVATE Qt6::Core Flora_libgit2_static) @@ -166,6 +173,7 @@ add_test(NAME flora_git_history_test COMMAND flora_git_history_test) add_executable(flora_vault_select_test vault_select_test.cpp ../src/app/VaultManager.cpp ../src/core/SettingsStore.cpp + ../src/core/DocumentManager.cpp ../src/git/GitManager.cpp ) target_include_directories(flora_vault_select_test PRIVATE ../src) diff --git a/tests/git_history_test.cpp b/tests/git_history_test.cpp index 4431461..25de507 100644 --- a/tests/git_history_test.cpp +++ b/tests/git_history_test.cpp @@ -154,6 +154,62 @@ int main(int argc, char *argv[]) bool autoAgain = git.autoCommit(vault, file); if(!autoAgain) return fail("auto again should be true even if no changes"); + // Пагинация: limit режет сверху, порядок новые сверху, кэш консистентен + { + QVariantList full = git.fileHistory(vault, file); + QVariantList part = git.fileHistory(vault, file, 2); + if(part.size()!=2) return fail("paged size", QString::number(part.size())); + if(part.first().toMap().value("hash") != full.first().toMap().value("hash")) + return fail("paged order"); + // повторный запрос с тем же лимитом — из кэша, тот же результат + QVariantList part2 = git.fileHistory(vault, file, 2); + if(part2.size()!=2 || part2.first().toMap().value("hash") != part.first().toMap().value("hash")) + return fail("paged cache"); + // limit<=0 — как раньше (всё) + if(git.fileHistory(vault, file, 0).size() != full.size()) return fail("limit 0"); + } + + // fileExistedAt: отличает «пустой файл» от «файла не было» + // (коммит удаления в историю файла не попадает — файла нет в дереве, + // поэтому false проверяем коммитом, предшествующим созданию файла) + { + if(!git.fileExistedAt(vault, file, hash1)) return fail("existed v1"); + const QString late = vault+"/late.md"; + QFile lf(late); + if(!lf.open(QIODevice::WriteOnly)) return fail("late create"); + lf.write("late\n"); + lf.close(); + if(!git.commitAll(vault, "late file")) return fail("commit late", git.lastError()); + QString lateHash = git.fileHistory(vault, late).first().toMap().value("hash").toString(); + if(!git.fileExistedAt(vault, late, lateHash)) return fail("late existed"); + if(git.fileExistedAt(vault, late, hash1)) return fail("late should not exist at v1"); + if(git.fileExistedAt(vault, late, "0000000000000000000000000000000000000000")) + return fail("bad hash existed"); + } + + // commitFile: точечный коммит одного файла (второй файл не трогаем) + { + QFile re(file); + if(!re.open(QIODevice::WriteOnly)) return fail("recreate"); + re.write("v-final\n"); + re.close(); + if(!git.commitFile(vault, file, "final A")) return fail("commitFile A", git.lastError()); + const int histA = git.fileHistory(vault, file).size(); + // правим оба файла + QFile fa(file); + fa.open(QIODevice::WriteOnly | QIODevice::Append); fa.write("more A\n"); fa.close(); + QFile fb(vault+"/other.md"); + fb.open(QIODevice::WriteOnly | QIODevice::Append); fb.write("more B\n"); fb.close(); + const int histBBefore = git.fileHistory(vault, vault+"/other.md").size(); + if(!git.commitFile(vault, file, "final B")) return fail("commitFile B", git.lastError()); + if(git.fileHistory(vault, file).size() != histA + 1) return fail("commitFile history A"); + if(git.fileHistory(vault, vault+"/other.md").size() != histBBefore) return fail("commitFile затронул B"); + if(!git.fileHistory(vault, file).first().toMap().value("message").toString().contains("final B")) + return fail("commitFile message"); + // commitFile без изменений — false с понятной ошибкой, как commitAll + if(git.commitFile(vault, file, "noop")) return fail("commitFile noop should fail"); + } + std::printf("GIT HISTORY TEST OK (forward/backward, diff, restore, time)\n"); return 0; } diff --git a/tests/mdparser_test.cpp b/tests/mdparser_test.cpp index d1b7b10..0e11067 100644 --- a/tests/mdparser_test.cpp +++ b/tests/mdparser_test.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include "markdown/MdParser.h" @@ -201,6 +203,37 @@ int main(int argc, char *argv[]) return fail("text add без зелёного"); } + // parseBlocksWithDiffAsync: тот же результат, что sync, id совпадает + { + const QString oldMd = "# T\n\n```cpp\nint a = 1;\n```\n"; + const QString newMd = "# T\n\n```cpp\nint a = 2;\n```\n"; + QVariantList expect = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir); + const int reqId = p.parseBlocksWithDiffAsync(oldMd, newMd, vault, fileDir); + QVariantList got; + int gotId = -1; + QObject::connect(&p, &MdParser::blocksReady, + [&](int id, const QVariantList &b) { gotId = id; got = b; }); + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&p, &MdParser::blocksReady, &loop, &QEventLoop::quit); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(10000); + loop.exec(); + if (gotId != reqId) return fail("async id", QString::number(gotId)); + if (got.size() != expect.size()) return fail("async размер", QString::number(got.size())); + for (int i = 0; i < got.size(); ++i) { + const QVariantMap g = got[i].toMap(), e = expect[i].toMap(); + if (g.value("type") != e.value("type") || g.value("change") != e.value("change")) + return fail("async блок", QString::number(i)); + if (g.value("type").toString() == "code") { + // копирование работает и после async (id свежие, код — новый) + if (!p.getCodeBlock(g.value("codeId").toString()).contains("int a = 2;")) + return fail("async copy", p.getCodeBlock(g.value("codeId").toString())); + } + } + } + // parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри { const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";