Git: кэш+пагинация истории, diff в потоке, точечный коммит, честное удаление
- История: кэш по vault|rel|headOid, fileHistory(vault,file,limit), в диалоге Показать ещё по 60 с сохранением выбора - Diff считается в QtConcurrent-пуле (parseBlocksWithDiffAsync+blocksReady, stale-guard), спиннер busy; MarkdownView.diffApplied - commitFile точечно вместо всего хранилища (общий commitFromIndex), autoCommit честный; fileExistedAt + willDelete в подтверждении - DocumentManager.readTextFile общий (GitManager.workdirContent через него); fileGitStatus через relativePath - Тесты: пагинация/кэш, existed, commitFile не трогает B, async==sync
This commit is contained in:
+2
-1
@@ -23,7 +23,7 @@ message(STATUS "OpenSSL: ${OPENSSL_VERSION}")
|
|||||||
# Qt 6
|
# Qt 6
|
||||||
find_package(Qt6 6.4 REQUIRED COMPONENTS
|
find_package(Qt6 6.4 REQUIRED COMPONENTS
|
||||||
Quick QuickControls2 QuickTemplates2 QuickLayouts
|
Quick QuickControls2 QuickTemplates2 QuickLayouts
|
||||||
Sql Network Pdf PrintSupport Svg)
|
Sql Network Pdf PrintSupport Svg Concurrent)
|
||||||
|
|
||||||
qt_standard_project_setup(REQUIRES 6.4)
|
qt_standard_project_setup(REQUIRES 6.4)
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@ target_link_libraries(flora_core PUBLIC
|
|||||||
Qt6::Pdf
|
Qt6::Pdf
|
||||||
Qt6::PrintSupport
|
Qt6::PrintSupport
|
||||||
Qt6::Svg
|
Qt6::Svg
|
||||||
|
Qt6::Concurrent
|
||||||
cmark::cmark
|
cmark::cmark
|
||||||
)
|
)
|
||||||
if(TARGET cmark::cmark_extensions)
|
if(TARGET cmark::cmark_extensions)
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ Dialog {
|
|||||||
property string currentContent: ""
|
property string currentContent: ""
|
||||||
property string emptyMessage: qsTr("Нет версии для просмотра")
|
property string emptyMessage: qsTr("Нет версии для просмотра")
|
||||||
property bool busy: false
|
property bool busy: false
|
||||||
|
// Пагинация: подгружаем порциями, чтобы revwalk не бежал по всей истории сразу
|
||||||
|
property int historyLimit: 60
|
||||||
|
property bool hasMore: false
|
||||||
|
|
||||||
function fileDirOf(p) {
|
function fileDirOf(p) {
|
||||||
const i = p.lastIndexOf("/")
|
const i = p.lastIndexOf("/")
|
||||||
@@ -66,13 +69,23 @@ Dialog {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
busy = true
|
busy = true
|
||||||
// берём историю
|
// берём историю порцией limit+1: лишний элемент — признак «есть ещё»
|
||||||
const hist = gitManager.fileHistory(vaultPath, filePath)
|
const keepHash = selectedHash
|
||||||
historyModel = hist
|
const hist = gitManager.fileHistory(vaultPath, filePath, historyLimit + 1)
|
||||||
listView.model = hist
|
hasMore = hist.length > historyLimit
|
||||||
if (hist.length>0) {
|
const shown = hasMore ? hist.slice(0, historyLimit) : hist
|
||||||
selectedIndex = 0
|
historyModel = shown
|
||||||
selectedHash = hist[0].hash
|
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()
|
updateDiff()
|
||||||
} else {
|
} else {
|
||||||
selectedIndex = -1
|
selectedIndex = -1
|
||||||
@@ -93,13 +106,15 @@ Dialog {
|
|||||||
if (selectedHash.length===0) {
|
if (selectedHash.length===0) {
|
||||||
versionContent = ""
|
versionContent = ""
|
||||||
currentContent = ""
|
currentContent = ""
|
||||||
|
busy = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
|
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
|
||||||
currentContent = gitManager.workdirContent(filePath)
|
currentContent = gitManager.workdirContent(filePath)
|
||||||
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
|
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
|
||||||
// с подсветкой отличий от текущего файла прямо там
|
// с подсветкой отличий от текущего файла прямо там (считается в рабочем потоке)
|
||||||
versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
|
versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
|
||||||
|
busy = currentContent.length > 0 && versionContent.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpened: reload()
|
onOpened: reload()
|
||||||
@@ -280,6 +295,12 @@ Dialog {
|
|||||||
font.family: "Menlo, monospace"
|
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 {}
|
ScrollBar.vertical: ScrollBar {}
|
||||||
Column {
|
Column {
|
||||||
width: versionScroll.availableWidth
|
width: versionScroll.availableWidth
|
||||||
MarkdownView {
|
MarkdownView {
|
||||||
id: versionPreview
|
id: versionPreview
|
||||||
width: parent.width
|
width: parent.width
|
||||||
markdown: root.versionContent
|
markdown: root.versionContent
|
||||||
compareMarkdown: root.currentContent
|
compareMarkdown: root.currentContent
|
||||||
vaultRoot: root.vaultPath
|
vaultRoot: root.vaultPath
|
||||||
fileDir: root.fileDirOf(root.filePath)
|
fileDir: root.fileDirOf(root.filePath)
|
||||||
emptyText: root.emptyMessage
|
emptyText: root.emptyMessage
|
||||||
onLinkActivated: (href) => root.handleHistoryLink(href)
|
onLinkActivated: (href) => root.handleHistoryLink(href)
|
||||||
}
|
onDiffApplied: root.busy = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,7 +377,10 @@ Dialog {
|
|||||||
text: qsTr("Восстановить эту версию")
|
text: qsTr("Восстановить эту версию")
|
||||||
enabled: root.selectedHash.length>0
|
enabled: root.selectedHash.length>0
|
||||||
highlighted: true
|
highlighted: true
|
||||||
onClicked: confirmRestore.open()
|
onClicked: {
|
||||||
|
confirmRestore.willDelete = !gitManager.fileExistedAt(root.vaultPath, root.filePath, root.selectedHash)
|
||||||
|
confirmRestore.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,6 +388,7 @@ Dialog {
|
|||||||
// Подтверждение восстановления
|
// Подтверждение восстановления
|
||||||
Dialog {
|
Dialog {
|
||||||
id: confirmRestore
|
id: confirmRestore
|
||||||
|
property bool willDelete: false
|
||||||
title: qsTr("Восстановить версию?")
|
title: qsTr("Восстановить версию?")
|
||||||
modal: true
|
modal: true
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
@@ -373,10 +399,12 @@ Dialog {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 12
|
spacing: 12
|
||||||
Label {
|
Label {
|
||||||
text: qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.")
|
text: confirmRestore.willDelete
|
||||||
|
? qsTr("ВНИМАНИЕ: в выбранной версии этого файла не было — файл будет УДАЛЁН (это тоже сохранится как новая версия, отменить можно через историю).")
|
||||||
|
: qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.")
|
||||||
wrapMode: Text.Wrap
|
wrapMode: Text.Wrap
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
color: Theme.text
|
color: confirmRestore.willDelete ? Theme.errorText : Theme.text
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: qsTr("Версия: %1 (%2)").arg(root.selectedHash.substring(0,7)).arg(root.selectedIndex>=0 && historyModel.length>root.selectedIndex ? historyModel[root.selectedIndex].timeStr : "")
|
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 }
|
Item { Layout.fillWidth: true }
|
||||||
Button { text: qsTr("Отмена"); onClicked: confirmRestore.close() }
|
Button { text: qsTr("Отмена"); onClicked: confirmRestore.close() }
|
||||||
Button {
|
Button {
|
||||||
text: qsTr("Восстановить")
|
text: confirmRestore.willDelete ? qsTr("Удалить файл") : qsTr("Восстановить")
|
||||||
highlighted: true
|
highlighted: true
|
||||||
onClicked: {
|
onClicked: {
|
||||||
confirmRestore.close()
|
confirmRestore.close()
|
||||||
|
|||||||
@@ -10,19 +10,24 @@ Item {
|
|||||||
property string fileDir: ""
|
property string fileDir: ""
|
||||||
property string emptyText: qsTr("Пустой файл — начните писать в режиме редактирования")
|
property string emptyText: qsTr("Пустой файл — начните писать в режиме редактирования")
|
||||||
// Если задан — markdown считается старой версией и подсвечиваются отличия от него
|
// Если задан — markdown считается старой версией и подсвечиваются отличия от него
|
||||||
// (блоки del — только в markdown, add — только в compareMarkdown)
|
// (блоки del — только в markdown, add — только в compareMarkdown).
|
||||||
|
// Сравнение считается в рабочем потоке, чтобы UI не висел на больших файлах.
|
||||||
property string compareMarkdown: ""
|
property string compareMarkdown: ""
|
||||||
|
property int _diffReq: -1
|
||||||
implicitHeight: column.implicitHeight + 24
|
implicitHeight: column.implicitHeight + 24
|
||||||
|
|
||||||
signal linkActivated(string href)
|
signal linkActivated(string href)
|
||||||
|
signal diffApplied()
|
||||||
|
|
||||||
// Для обновления
|
// Для обновления
|
||||||
function update() {
|
function update() {
|
||||||
|
if (compareMarkdown.length > 0) {
|
||||||
|
_diffReq = mdParser.parseBlocksWithDiffAsync(markdown, compareMarkdown, vaultRoot, fileDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Триггерим пересборку блоков
|
// Триггерим пересборку блоков
|
||||||
blocksModel.clear()
|
blocksModel.clear()
|
||||||
const blocks = compareMarkdown.length > 0
|
const blocks = mdParser.parseBlocks(markdown, vaultRoot, fileDir)
|
||||||
? mdParser.parseBlocksWithDiff(markdown, compareMarkdown, vaultRoot, fileDir)
|
|
||||||
: mdParser.parseBlocks(markdown, vaultRoot, fileDir)
|
|
||||||
for (let i = 0; i < blocks.length; i++) {
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
blocksModel.append(blocks[i])
|
blocksModel.append(blocks[i])
|
||||||
}
|
}
|
||||||
@@ -30,6 +35,19 @@ Item {
|
|||||||
// contentItem.contentY = 0
|
// 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()
|
onMarkdownChanged: update()
|
||||||
onCompareMarkdownChanged: update()
|
onCompareMarkdownChanged: update()
|
||||||
onVaultRootChanged: update()
|
onVaultRootChanged: update()
|
||||||
|
|||||||
@@ -127,8 +127,15 @@ int DocumentManager::openDocument(const QString &path)
|
|||||||
|
|
||||||
QString DocumentManager::readFile(const QString &path, bool *ok) const
|
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);
|
QFileInfo fi(path);
|
||||||
if (fi.exists() && fi.size() > 10 * 1024 * 1024) {
|
if (fi.exists() && fi.size() > maxBytes) {
|
||||||
if (ok) *ok = false;
|
if (ok) *ok = false;
|
||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
@@ -137,9 +144,9 @@ QString DocumentManager::readFile(const QString &path, bool *ok) const
|
|||||||
if (ok) *ok = false;
|
if (ok) *ok = false;
|
||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
// Ограничиваем чтение 10 МБ для защиты от OOM
|
// Ограничиваем чтение для защиты от OOM
|
||||||
const QByteArray bytes = f.read(10 * 1024 * 1024 + 1);
|
const QByteArray bytes = f.read(maxBytes + 1);
|
||||||
if (bytes.size() > 10 * 1024 * 1024) {
|
if (bytes.size() > maxBytes) {
|
||||||
if (ok) *ok = false;
|
if (ok) *ok = false;
|
||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ private:
|
|||||||
void ensureAutosave();
|
void ensureAutosave();
|
||||||
QString readFile(const QString &path, bool *ok) const;
|
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<Doc> m_docs;
|
QList<Doc> m_docs;
|
||||||
int m_active = -1;
|
int m_active = -1;
|
||||||
int m_secondary = -1;
|
int m_secondary = -1;
|
||||||
|
|||||||
+170
-81
@@ -1,5 +1,7 @@
|
|||||||
#include "git/GitManager.h"
|
#include "git/GitManager.h"
|
||||||
|
|
||||||
|
#include "core/DocumentManager.h"
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
@@ -188,29 +190,13 @@ QString GitManager::fileGitStatus(const QString &vaultPath, const QString &fileP
|
|||||||
if (vaultPath.isEmpty() || filePath.isEmpty())
|
if (vaultPath.isEmpty() || filePath.isEmpty())
|
||||||
return QStringLiteral("—");
|
return QStringLiteral("—");
|
||||||
const QString vault = QDir::cleanPath(vaultPath);
|
const QString vault = QDir::cleanPath(vaultPath);
|
||||||
const QString file = QDir::cleanPath(filePath);
|
const QString rel = relativePath(vaultPath, filePath);
|
||||||
if (!file.startsWith(vault + '/') && file != vault)
|
if (rel.isEmpty())
|
||||||
return QStringLiteral("—");
|
return QStringLiteral("—");
|
||||||
git_repository *repo = nullptr;
|
git_repository *repo = nullptr;
|
||||||
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
||||||
return QStringLiteral("—");
|
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;
|
unsigned int status = 0;
|
||||||
const int rc = git_status_file(&status, repo, rel.toUtf8().constData());
|
const int rc = git_status_file(&status, repo, rel.toUtf8().constData());
|
||||||
git_repository_free(repo);
|
git_repository_free(repo);
|
||||||
@@ -252,6 +238,73 @@ QString GitManager::fileGitStatusShort(const QString &vaultPath, const QString &
|
|||||||
return s;
|
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<git_commit *>(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)
|
bool GitManager::commitAll(const QString &path, const QString &message)
|
||||||
{
|
{
|
||||||
const QString cleanPath = QDir::cleanPath(path);
|
const QString cleanPath = QDir::cleanPath(path);
|
||||||
@@ -268,78 +321,74 @@ bool GitManager::commitAll(const QString &path, const QString &message)
|
|||||||
|
|
||||||
// Добавляем все файлы, включая новые и удаления (с учётом .gitignore)
|
// Добавляем все файлы, включая новые и удаления (с учётом .gitignore)
|
||||||
git_index_add_all(info.index, nullptr, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr);
|
git_index_add_all(info.index, nullptr, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr);
|
||||||
const int wrc = git_index_write(info.index);
|
if (git_index_write(info.index) < 0) {
|
||||||
if (wrc < 0) {
|
|
||||||
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
|
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов,
|
return commitFromIndex(info.repo, info.index, message);
|
||||||
// что тоже нужно коммитить. Проверяем равенство дерева ниже.
|
}
|
||||||
|
|
||||||
// Создаём дерево
|
bool GitManager::commitFile(const QString &vaultPath, const QString &filePath, const QString &message)
|
||||||
git_tree *tree = nullptr;
|
{
|
||||||
if (git_index_write_tree(&info.treeId, info.index) < 0
|
const QString vault = QDir::cleanPath(vaultPath);
|
||||||
|| git_tree_lookup(&tree, info.repo, &info.treeId) < 0) {
|
const QString rel = relativePath(vaultPath, filePath);
|
||||||
setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr()));
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Родитель: head, если коммиты уже есть
|
if (git_repository_index(&info.index, info.repo) < 0) {
|
||||||
git_reference *headRef = nullptr;
|
setLastError(QStringLiteral("Не удалось открыть индекс: %1").arg(gitErr()));
|
||||||
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<git_commit *>(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()));
|
|
||||||
return false;
|
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
|
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;
|
QVariantList out;
|
||||||
|
if (limit <= 0)
|
||||||
|
limit = 500;
|
||||||
|
limit = qMin(limit, 2000);
|
||||||
const QString vault = QDir::cleanPath(vaultPath);
|
const QString vault = QDir::cleanPath(vaultPath);
|
||||||
const QString rel = relativePath(vaultPath, filePath);
|
const QString rel = relativePath(vaultPath, filePath);
|
||||||
if (vault.isEmpty() || rel.isEmpty())
|
if (vault.isEmpty() || rel.isEmpty())
|
||||||
@@ -351,6 +400,13 @@ QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &fi
|
|||||||
git_repository_free(repo);
|
git_repository_free(repo);
|
||||||
return out;
|
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;
|
git_revwalk *walk = nullptr;
|
||||||
if (git_revwalk_new(&walk, repo) < 0) {
|
if (git_revwalk_new(&walk, repo) < 0) {
|
||||||
git_repository_free(repo);
|
git_repository_free(repo);
|
||||||
@@ -361,7 +417,7 @@ QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &fi
|
|||||||
|
|
||||||
git_oid oid;
|
git_oid oid;
|
||||||
int iter = 0;
|
int iter = 0;
|
||||||
const int maxIter = 500; // ограничение для скорости
|
const int maxIter = limit; // ограничение для скорости
|
||||||
while (git_revwalk_next(&oid, walk) == 0 && iter < maxIter) {
|
while (git_revwalk_next(&oid, walk) == 0 && iter < maxIter) {
|
||||||
++iter;
|
++iter;
|
||||||
git_commit *commit = nullptr;
|
git_commit *commit = nullptr;
|
||||||
@@ -444,6 +500,10 @@ QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &fi
|
|||||||
}
|
}
|
||||||
git_revwalk_free(walk);
|
git_revwalk_free(walk);
|
||||||
git_repository_free(repo);
|
git_repository_free(repo);
|
||||||
|
if (!head.isEmpty()) {
|
||||||
|
m_histKey = key;
|
||||||
|
m_histCache = out;
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +548,37 @@ QString GitManager::fileContentAt(const QString &vaultPath, const QString &fileP
|
|||||||
return result;
|
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)
|
QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash)
|
||||||
{
|
{
|
||||||
const QString vault = QDir::cleanPath(vaultPath);
|
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
|
QString GitManager::workdirContent(const QString &filePath) const
|
||||||
{
|
{
|
||||||
QFile f(filePath);
|
return DocumentManager::readTextFile(filePath, 2LL * 1024 * 1024);
|
||||||
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
|
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()
|
const QString msg = fileName.isEmpty()
|
||||||
? QStringLiteral("Автосохранение")
|
? QStringLiteral("Автосохранение")
|
||||||
: QStringLiteral("Автосохранение: %1").arg(fileName);
|
: QStringLiteral("Автосохранение: %1").arg(fileName);
|
||||||
// commitAll вернёт false если нет изменений — это нормально
|
// Точечный коммит одного файла (а не всего хранилища) — сообщение честное.
|
||||||
bool ok = commitAll(vaultPath, msg);
|
// commitFile вернёт false если нет изменений — это нормально
|
||||||
|
bool ok = commitFile(vaultPath, filePath, msg);
|
||||||
if (!ok && lastError() == QStringLiteral("Нет изменений для коммита")) {
|
if (!ok && lastError() == QStringLiteral("Нет изменений для коммита")) {
|
||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
return true; // считаем успехом
|
return true; // считаем успехом
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public:
|
|||||||
// Коммитит все изменения в хранилище (рабочее дерево). Возвращает true/false.
|
// Коммитит все изменения в хранилище (рабочее дерево). Возвращает true/false.
|
||||||
Q_INVOKABLE bool commitAll(const QString &path, const QString &message);
|
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;
|
Q_INVOKABLE bool hasCommits(const QString &path) const;
|
||||||
|
|
||||||
@@ -34,10 +38,18 @@ public:
|
|||||||
// История файла: список коммитов где файл менялся. Сортировка новые сверху.
|
// История файла: список коммитов где файл менялся. Сортировка новые сверху.
|
||||||
// Каждый элемент: {hash, shortHash, message, author, time (QDateTime), timeStr, timeAgo}
|
// Каждый элемент: {hash, shortHash, message, author, time (QDateTime), timeStr, timeAgo}
|
||||||
Q_INVOKABLE QVariantList fileHistory(const QString &vaultPath, const QString &filePath) const;
|
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). Пусто если файл не существовал.
|
// Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал.
|
||||||
Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
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 МБ.
|
// Текущее содержимое файла на диске (для сравнения с версией). Пусто при ошибке/лимите 2 МБ.
|
||||||
Q_INVOKABLE QString workdirContent(const QString &filePath) const;
|
Q_INVOKABLE QString workdirContent(const QString &filePath) const;
|
||||||
|
|
||||||
@@ -66,7 +78,15 @@ private:
|
|||||||
void setLastError(const QString &msg);
|
void setLastError(const QString &msg);
|
||||||
QString relativePath(const QString &vaultPath, const QString &filePath) const;
|
QString relativePath(const QString &vaultPath, const QString &filePath) const;
|
||||||
void ensureGitignore(const QString &vaultPath);
|
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;
|
QString m_lastError;
|
||||||
static bool s_gitInitialized;
|
static bool s_gitInitialized;
|
||||||
|
|
||||||
|
// Кэш истории: ключ vault|rel|headOid. История меняется только с HEAD.
|
||||||
|
mutable QString m_histKey;
|
||||||
|
mutable QVariantList m_histCache;
|
||||||
};
|
};
|
||||||
+82
-24
@@ -20,12 +20,14 @@
|
|||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QFutureWatcher>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QImageReader>
|
#include <QImageReader>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QTime>
|
#include <QTime>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
#include <QtConcurrentRun>
|
||||||
|
|
||||||
using KSyntaxHighlighting::AbstractHighlighter;
|
using KSyntaxHighlighting::AbstractHighlighter;
|
||||||
using KSyntaxHighlighting::Definition;
|
using KSyntaxHighlighting::Definition;
|
||||||
@@ -854,8 +856,10 @@ QString MdParser::render(const QString &markdown, const QString &vaultRoot,
|
|||||||
return renderInner(markdown, vaultRoot, fileDir);
|
return renderInner(markdown, vaultRoot, fileDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
QString MdParser::renderInner(const QString &markdown, const QString &vaultRoot,
|
// Файлово-статический рендер с явным Impl: безопасен для рабочего потока
|
||||||
const QString &fileDir)
|
// (свой парсер cmark на вызов, никакой разделяемой мутации кроме переданного impl).
|
||||||
|
static QString renderWithImpl(MdParser::Impl *impl, 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);
|
||||||
@@ -868,7 +872,7 @@ QString MdParser::renderInner(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);
|
||||||
|
|
||||||
Renderer r(m, vaultRoot, fileDir);
|
Renderer r(impl, vaultRoot, fileDir);
|
||||||
QString html = r.renderTree(doc);
|
QString html = r.renderTree(doc);
|
||||||
|
|
||||||
cmark_node_free(doc);
|
cmark_node_free(doc);
|
||||||
@@ -876,6 +880,12 @@ QString MdParser::renderInner(const QString &markdown, const QString &vaultRoot,
|
|||||||
return html;
|
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 MdParser::parseBlocks(const QString &markdown, const QString &vaultRoot, const QString &fileDir)
|
||||||
{
|
{
|
||||||
QVariantList blocks;
|
QVariantList blocks;
|
||||||
@@ -981,13 +991,16 @@ QString MdParser::highlightedCode(const QString &code, const QString &language)
|
|||||||
return hl.highlight(code, lang, true);
|
return hl.highlight(code, lang, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &newMd,
|
// Потокобезопасное ядро diff-блоков: работает с явно переданным Impl
|
||||||
const QString &vaultRoot, const QString &fileDir)
|
// (в главном потоке — m, в рабочем — локальный), состояние только в нём.
|
||||||
|
static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd,
|
||||||
|
const QString &newMd, const QString &vaultRoot,
|
||||||
|
const QString &fileDir)
|
||||||
{
|
{
|
||||||
QVariantList blocks;
|
QVariantList blocks;
|
||||||
if (m) {
|
if (impl) {
|
||||||
m->codeBlocks.clear();
|
impl->codeBlocks.clear();
|
||||||
m->codeCounter = 0;
|
impl->codeCounter = 0;
|
||||||
}
|
}
|
||||||
if (oldMd.isEmpty() && newMd.isEmpty())
|
if (oldMd.isEmpty() && newMd.isEmpty())
|
||||||
return blocks;
|
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) {
|
auto appendCodeBlock = [&](const QString &lang, const QString &code, const char *change, bool tint) {
|
||||||
QString codeId;
|
QString codeId;
|
||||||
if (m) {
|
if (impl) {
|
||||||
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
codeId = QStringLiteral("code_%1").arg(impl->codeCounter++);
|
||||||
m->codeBlocks.insert(codeId, code);
|
impl->codeBlocks.insert(codeId, code);
|
||||||
}
|
}
|
||||||
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
CodeHighlighter hl(impl ? impl->m_repo : nullptr);
|
||||||
QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true)
|
QString highlighted = (impl && impl->m_repo) ? hl.highlight(code, lang, true)
|
||||||
: plainToRich(code);
|
: plainToRich(code);
|
||||||
if (tint)
|
if (tint)
|
||||||
highlighted = tintDiffHtml(highlighted, change[0] == 'd');
|
highlighted = tintDiffHtml(highlighted, change[0] == 'd');
|
||||||
QVariantMap block;
|
QVariantMap block;
|
||||||
@@ -1030,11 +1043,11 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
// подсветкой внутри — изменения видны там, где сделаны. Копируется новое.
|
// подсветкой внутри — изменения видны там, где сделаны. Копируется новое.
|
||||||
auto appendMergedCode = [&](const QString &oldLang, const QString &oldCode,
|
auto appendMergedCode = [&](const QString &oldLang, const QString &oldCode,
|
||||||
const QString &newLang, const QString &newCode) -> bool {
|
const QString &newLang, const QString &newCode) -> bool {
|
||||||
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
CodeHighlighter hl(impl ? impl->m_repo : nullptr);
|
||||||
const QString oldHl = (m && m->m_repo) ? hl.highlight(oldCode, oldLang, true)
|
const QString oldHl = (impl && impl->m_repo) ? hl.highlight(oldCode, oldLang, true)
|
||||||
: plainToRich(oldCode);
|
: plainToRich(oldCode);
|
||||||
const QString newHl = (m && m->m_repo) ? hl.highlight(newCode, newLang, true)
|
const QString newHl = (impl && impl->m_repo) ? hl.highlight(newCode, newLang, true)
|
||||||
: plainToRich(newCode);
|
: plainToRich(newCode);
|
||||||
const QStringList oldLines = oldCode.split(QLatin1Char('\n'));
|
const QStringList oldLines = oldCode.split(QLatin1Char('\n'));
|
||||||
const QStringList newLines = newCode.split(QLatin1Char('\n'));
|
const QStringList newLines = newCode.split(QLatin1Char('\n'));
|
||||||
const QStringList oldFrag = oldHl.split(QStringLiteral("<br/>"));
|
const QStringList oldFrag = oldHl.split(QStringLiteral("<br/>"));
|
||||||
@@ -1072,9 +1085,9 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
flushPending();
|
flushPending();
|
||||||
const QString lang = !newLang.isEmpty() ? newLang : oldLang;
|
const QString lang = !newLang.isEmpty() ? newLang : oldLang;
|
||||||
QString codeId;
|
QString codeId;
|
||||||
if (m) {
|
if (impl) {
|
||||||
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
codeId = QStringLiteral("code_%1").arg(impl->codeCounter++);
|
||||||
m->codeBlocks.insert(codeId, newCode);
|
impl->codeBlocks.insert(codeId, newCode);
|
||||||
}
|
}
|
||||||
QVariantMap block;
|
QVariantMap block;
|
||||||
block["type"] = "code";
|
block["type"] = "code";
|
||||||
@@ -1109,7 +1122,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 = renderInner(op.text, vaultRoot, fileDir);
|
QString html = renderWithImpl(impl, op.text, vaultRoot, fileDir);
|
||||||
if (html.trimmed().isEmpty())
|
if (html.trimmed().isEmpty())
|
||||||
continue;
|
continue;
|
||||||
if (changed)
|
if (changed)
|
||||||
@@ -1121,9 +1134,54 @@ 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 = renderInner(src, vaultRoot, fileDir);
|
const QString html = renderWithImpl(impl, src, vaultRoot, fileDir);
|
||||||
if (!html.trimmed().isEmpty())
|
if (!html.trimmed().isEmpty())
|
||||||
appendHtmlBlock(html, "same");
|
appendHtmlBlock(html, "same");
|
||||||
}
|
}
|
||||||
return blocks;
|
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<QVariantList> *watcher = new QFutureWatcher<QVariantList>(this);
|
||||||
|
connect(watcher, &QFutureWatcher<QVariantList>::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;
|
||||||
|
}
|
||||||
@@ -36,10 +36,22 @@ public:
|
|||||||
const QString &vaultRoot,
|
const QString &vaultRoot,
|
||||||
const QString &fileDir);
|
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 QString getCodeBlock(const QString &id) const;
|
||||||
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
||||||
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
|
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void blocksReady(int requestId, const QVariantList &blocks);
|
||||||
|
|
||||||
|
public:
|
||||||
struct Impl;
|
struct Impl;
|
||||||
private:
|
private:
|
||||||
// Внутренний рендер без сброса codeBlocks/codeCounter (для parseBlocks*,
|
// Внутренний рендер без сброса codeBlocks/codeCounter (для parseBlocks*,
|
||||||
@@ -49,4 +61,5 @@ private:
|
|||||||
const QString &fileDir);
|
const QString &fileDir);
|
||||||
|
|
||||||
Impl *m = nullptr;
|
Impl *m = nullptr;
|
||||||
|
int m_asyncId = 0;
|
||||||
};
|
};
|
||||||
+10
-2
@@ -13,6 +13,8 @@ add_test(NAME flora_smoke COMMAND flora_smoke)
|
|||||||
add_executable(flora_git_test git_test.cpp
|
add_executable(flora_git_test git_test.cpp
|
||||||
../src/git/GitManager.cpp
|
../src/git/GitManager.cpp
|
||||||
../src/git/GitManager.h
|
../src/git/GitManager.h
|
||||||
|
../src/core/DocumentManager.cpp
|
||||||
|
../src/core/DocumentManager.h
|
||||||
)
|
)
|
||||||
target_include_directories(flora_git_test PRIVATE ../src)
|
target_include_directories(flora_git_test PRIVATE ../src)
|
||||||
target_compile_definitions(flora_git_test PRIVATE CMARK_GFM_STATIC_DEFINE)
|
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/app/VaultManager.h
|
||||||
../src/core/SettingsStore.cpp
|
../src/core/SettingsStore.cpp
|
||||||
../src/core/SettingsStore.h
|
../src/core/SettingsStore.h
|
||||||
|
../src/core/DocumentManager.cpp
|
||||||
|
../src/core/DocumentManager.h
|
||||||
../src/git/GitManager.cpp
|
../src/git/GitManager.cpp
|
||||||
../src/git/GitManager.h
|
../src/git/GitManager.h
|
||||||
)
|
)
|
||||||
@@ -68,6 +72,7 @@ target_compile_definitions(flora_mdparser_test PRIVATE
|
|||||||
target_link_libraries(flora_mdparser_test PRIVATE
|
target_link_libraries(flora_mdparser_test PRIVATE
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
Qt6::Gui
|
Qt6::Gui
|
||||||
|
Qt6::Concurrent
|
||||||
cmark::cmark
|
cmark::cmark
|
||||||
cmark::cmark_extensions
|
cmark::cmark_extensions
|
||||||
)
|
)
|
||||||
@@ -119,7 +124,7 @@ add_executable(flora_extended_test extended_test.cpp
|
|||||||
)
|
)
|
||||||
target_include_directories(flora_extended_test PRIVATE ../src)
|
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_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)
|
if(TARGET KF6SyntaxHighlighting)
|
||||||
target_link_libraries(flora_extended_test PRIVATE KF6SyntaxHighlighting)
|
target_link_libraries(flora_extended_test PRIVATE KF6SyntaxHighlighting)
|
||||||
endif()
|
endif()
|
||||||
@@ -147,7 +152,7 @@ add_executable(flora_integration_test integration_test.cpp
|
|||||||
)
|
)
|
||||||
target_include_directories(flora_integration_test PRIVATE ../src)
|
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_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)
|
if(TARGET KF6SyntaxHighlighting)
|
||||||
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
|
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
|
||||||
endif()
|
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
|
add_executable(flora_git_history_test git_history_test.cpp
|
||||||
../src/git/GitManager.cpp
|
../src/git/GitManager.cpp
|
||||||
../src/git/GitManager.h
|
../src/git/GitManager.h
|
||||||
|
../src/core/DocumentManager.cpp
|
||||||
|
../src/core/DocumentManager.h
|
||||||
)
|
)
|
||||||
target_include_directories(flora_git_history_test PRIVATE ../src)
|
target_include_directories(flora_git_history_test PRIVATE ../src)
|
||||||
target_link_libraries(flora_git_history_test PRIVATE Qt6::Core Flora_libgit2_static)
|
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
|
add_executable(flora_vault_select_test vault_select_test.cpp
|
||||||
../src/app/VaultManager.cpp
|
../src/app/VaultManager.cpp
|
||||||
../src/core/SettingsStore.cpp
|
../src/core/SettingsStore.cpp
|
||||||
|
../src/core/DocumentManager.cpp
|
||||||
../src/git/GitManager.cpp
|
../src/git/GitManager.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(flora_vault_select_test PRIVATE ../src)
|
target_include_directories(flora_vault_select_test PRIVATE ../src)
|
||||||
|
|||||||
@@ -154,6 +154,62 @@ int main(int argc, char *argv[])
|
|||||||
bool autoAgain = git.autoCommit(vault, file);
|
bool autoAgain = git.autoCommit(vault, file);
|
||||||
if(!autoAgain) return fail("auto again should be true even if no changes");
|
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");
|
std::printf("GIT HISTORY TEST OK (forward/backward, diff, restore, time)\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QTimer>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
#include "markdown/MdParser.h"
|
#include "markdown/MdParser.h"
|
||||||
@@ -201,6 +203,37 @@ int main(int argc, char *argv[])
|
|||||||
return fail("text add без зелёного");
|
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 внутри
|
// parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри
|
||||||
{
|
{
|
||||||
const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";
|
const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user