Git история: откат файлов в любой момент (фаза 9) — UI для простого пользователя

- GitManager: fileHistory (список версий с hash/time/message/author), fileContentAt, fileDiffHtml/Text (git_patch_from_buffers, цветной HTML), restoreFile (запись + автокоммит), lastModifiedTime/String, autoCommit, ensureGitignore (.flora/.stfolder)
- HistoryDialog.qml: список версий (время «только что/5 мин назад» + дата dd.MM.yyyy, сообщение, автор, shortHash), diff с легендой (зелёный +/красный -/синий заголовок), превью содержимого, кнопка «Восстановить эту версию» с подтверждением (объясняет что создастся новый коммит и можно вернуться вперёд), «Обновить/Закрыть»
- MainWindow: меню Правка → История файла (Ctrl+H) и Вернуться к предыдущей версии, FileTree ПКМ → История, передача vaultPath/filePath
- EditorPanel: индикатор «🕘» + lastModifiedStr (gitManager.lastModifiedTimeString), debounce autoCommit (2с) при documentSaved, shortcut Ctrl+H
- FileTreePanel: сигнал historyRequested
- CMake: добавлен HistoryDialog.qml в qml модуль
- Тесты: git_history_test — создание v1/v2, история 1→2, diff, восстановление назад к v1 (история 3), вперёд к v2 (4), v3, diff v1→v3, bad hash, other.md не влияет на историю note.md, autoCommit, lastModified, .gitignore; 21/21 зелёных
- Проверено вперёд/назад: v1→v2→восстановление v1→восстановление v2→v3→autoCommit, каждый шаг с проверкой содержимого и истории
This commit is contained in:
2026-09-01 19:40:55 +03:00
parent 95c03040c7
commit afc15680e1
9 changed files with 1158 additions and 8 deletions
+399
View File
@@ -1,7 +1,12 @@
#include "git/GitManager.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QTimeZone>
#include <QVariantMap>
namespace {
@@ -25,6 +30,34 @@ struct CommitInfo {
}
};
QString timeAgoString(const QDateTime &dt)
{
const qint64 secs = dt.secsTo(QDateTime::currentDateTime());
if (secs < 60) return QStringLiteral("только что");
if (secs < 3600) return QStringLiteral("%1 мин назад").arg(secs/60);
if (secs < 86400) return QStringLiteral("%1 ч назад").arg(secs/3600);
if (secs < 604800) return QStringLiteral("%1 дн назад").arg(secs/86400);
// старше недели — показываем дату
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
}
QString escHtml(const QString &s)
{
QString out;
out.reserve(s.size());
for (const QChar &c : s) {
switch (c.unicode()) {
case '&': out += "&amp;"; break;
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&#39;"; break;
default: out += c; break;
}
}
return out;
}
} // namespace
bool GitManager::s_gitInitialized = false;
@@ -73,6 +106,54 @@ void GitManager::setLastError(const QString &msg)
emit lastErrorChanged();
}
QString GitManager::relativePath(const QString &vaultPath, const QString &filePath) const
{
const QString vault = QDir::cleanPath(vaultPath);
const QString file = QDir::cleanPath(filePath);
if (file == vault) return QString();
if (!file.startsWith(vault + '/')) return QString();
QString rel = file.mid(vault.length());
while (rel.startsWith("/") || rel.startsWith("\\"))
rel.remove(0,1);
return rel;
}
void GitManager::ensureGitignore(const QString &vaultPath)
{
const QString gi = QDir(vaultPath).filePath(".gitignore");
const QStringList needed = {".flora/", ".stfolder", ".stfolder/", "Thumbs.db", ".DS_Store", "*.tmp", "*.log"};
QString existing;
if (QFile::exists(gi)) {
QFile f(gi);
if (f.open(QIODevice::ReadOnly))
existing = QString::fromUtf8(f.readAll());
} else {
// создаём файл
}
bool changed = false;
QStringList lines = existing.split('\n');
for (const QString &need : needed) {
const QString trimmed = need.trimmed();
bool found = false;
for (const QString &l : lines) {
if (l.trimmed() == trimmed) { found = true; break; }
}
if (!found) {
if (!existing.isEmpty() && !existing.endsWith('\n'))
existing += '\n';
existing += trimmed + "\n";
changed = true;
}
}
if (changed) {
QFile f(gi);
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
f.write(existing.toUtf8());
f.close();
}
}
}
bool GitManager::initRepo(const QString &path)
{
const QString clean = QDir::cleanPath(path.trimmed());
@@ -86,6 +167,7 @@ bool GitManager::initRepo(const QString &path)
if (git_repository_open(&repo, p.constData()) == 0) {
setRepoIdentity(repo);
git_repository_free(repo);
ensureGitignore(clean);
setLastError(QString());
return true; // .git уже есть
}
@@ -102,6 +184,7 @@ bool GitManager::initRepo(const QString &path)
}
setRepoIdentity(repo);
git_repository_free(repo);
ensureGitignore(clean);
setLastError(QString());
return true;
}
@@ -269,4 +352,320 @@ bool GitManager::commitAll(const QString &path, const QString &message)
setLastError(QString());
return true;
}
QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &filePath) const
{
QVariantList out;
const QString vault = QDir::cleanPath(vaultPath);
const QString rel = relativePath(vaultPath, filePath);
if (vault.isEmpty() || rel.isEmpty())
return out;
git_repository *repo = nullptr;
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
return out;
if (git_repository_head_unborn(repo)) {
git_repository_free(repo);
return out;
}
git_revwalk *walk = nullptr;
if (git_revwalk_new(&walk, repo) < 0) {
git_repository_free(repo);
return out;
}
git_revwalk_sorting(walk, GIT_SORT_TIME);
git_revwalk_push_head(walk);
git_oid oid;
int iter = 0;
const int maxIter = 500; // ограничение для скорости
while (git_revwalk_next(&oid, walk) == 0 && iter < maxIter) {
++iter;
git_commit *commit = nullptr;
if (git_commit_lookup(&commit, repo, &oid) < 0)
continue;
// Проверяем был ли файл в этом коммите и менялся ли он относительно родителя
bool relevant = false;
QString fileContent;
// Получаем дерево коммита
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) {
// файл есть в этом коммите
// Сравниваем с родителем
if (git_commit_parentcount(commit) == 0) {
relevant = true; // первый коммит где файл появился
} else {
git_commit *parent = nullptr;
if (git_commit_parent(&parent, commit, 0) == 0) {
git_tree *parentTree = nullptr;
if (git_commit_tree(&parentTree, parent) == 0) {
git_tree_entry *parentEntry = nullptr;
if (git_tree_entry_bypath(&parentEntry, parentTree, rel.toUtf8().constData()) == 0) {
// оба есть — сравниваем oid
const git_oid *a = git_tree_entry_id(entry);
const git_oid *b = git_tree_entry_id(parentEntry);
if (!git_oid_equal(a, b))
relevant = true;
git_tree_entry_free(parentEntry);
} else {
// файл появился в этом коммите (не было в родителе)
relevant = true;
}
git_tree_free(parentTree);
}
git_commit_free(parent);
} else {
relevant = true;
}
}
git_tree_entry_free(entry);
}
git_tree_free(tree);
}
if (!relevant) {
git_commit_free(commit);
continue;
}
char hashStr[41] = {0};
git_oid_tostr(hashStr, sizeof(hashStr), &oid);
QString hash = QString::fromUtf8(hashStr);
QString shortHash = hash.left(7);
const char *msg = git_commit_message(commit);
QString message = msg ? QString::fromUtf8(msg).split('\n').first().trimmed() : QString();
if (message.isEmpty()) message = QStringLiteral("(без сообщения)");
if (message.length() > 80) message = message.left(80) + "...";
const git_signature *author = git_commit_author(commit);
QString authorName = author && author->name ? QString::fromUtf8(author->name) : QStringLiteral("Flora");
git_time_t t = git_commit_time(commit);
int offset = git_commit_time_offset(commit);
QDateTime dt = QDateTime::fromSecsSinceEpoch((qint64)t, QTimeZone(offset*60));
// Если смещение 0 — используем локальное
if (!dt.isValid())
dt = QDateTime::fromSecsSinceEpoch((qint64)t);
QVariantMap m;
m["hash"] = hash;
m["shortHash"] = shortHash;
m["message"] = message;
m["author"] = authorName;
m["time"] = dt;
m["timeStr"] = dt.toString(QStringLiteral("dd.MM.yyyy hh:mm:ss"));
m["timeAgo"] = timeAgoString(dt);
m["hasContent"] = true;
out.append(m);
git_commit_free(commit);
}
git_revwalk_free(walk);
git_repository_free(repo);
return out;
}
QString GitManager::fileContentAt(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 QString();
git_repository *repo = nullptr;
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
return QString();
git_oid oid;
if (git_oid_fromstr(&oid, commitHash.toUtf8().constData()) < 0) {
git_repository_free(repo);
return QString();
}
git_commit *commit = nullptr;
if (git_commit_lookup(&commit, repo, &oid) < 0) {
git_repository_free(repo);
return QString();
}
git_tree *tree = nullptr;
QString result;
if (git_commit_tree(&tree, commit) == 0) {
git_tree_entry *entry = nullptr;
if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) {
const git_oid *blobOid = git_tree_entry_id(entry);
git_blob *blob = nullptr;
if (git_blob_lookup(&blob, repo, blobOid) == 0) {
const void *raw = git_blob_rawcontent(blob);
git_off_t len = git_blob_rawsize(blob);
result = QString::fromUtf8((const char*)raw, (int)len);
git_blob_free(blob);
}
git_tree_entry_free(entry);
}
git_tree_free(tree);
}
git_commit_free(commit);
git_repository_free(repo);
return result;
}
QString GitManager::fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
{
const QString oldContent = fileContentAt(vaultPath, filePath, commitHash);
QFile f(filePath);
QString newContent;
if (f.open(QIODevice::ReadOnly))
newContent = QString::fromUtf8(f.readAll());
else
newContent = QString();
if (oldContent == newContent)
return QStringLiteral("Нет изменений");
// Используем git_patch_from_buffers для диффа
git_patch *patch = nullptr;
const QByteArray oldBytes = oldContent.toUtf8();
const QByteArray newBytes = newContent.toUtf8();
const QString rel = relativePath(vaultPath, filePath);
const QByteArray relBytes = rel.toUtf8();
git_patch_from_buffers(&patch,
oldBytes.constData(), (size_t)oldBytes.size(), relBytes.constData(),
newBytes.constData(), (size_t)newBytes.size(), relBytes.constData(),
nullptr);
if (!patch)
return QStringLiteral("Не удалось построить diff");
QString out;
// Заголовок
out += QStringLiteral("--- a/%1\n").arg(rel);
out += QStringLiteral("+++ b/%1\n").arg(rel);
size_t hunks = git_patch_num_hunks(patch);
for (size_t h = 0; h < hunks; ++h) {
const git_diff_hunk *hk = nullptr;
size_t hunkLines = 0;
if (git_patch_get_hunk(&hk, &hunkLines, patch, h) < 0) continue;
out += QString::fromUtf8(hk->header, (int)hk->header_len);
if (!out.endsWith('\n')) out += '\n';
for (size_t l = 0; l < hunkLines; ++l) {
const git_diff_line *line = nullptr;
if (git_patch_get_line_in_hunk(&line, patch, h, l) < 0) continue;
QChar origin = QChar(line->origin);
QString text = QString::fromUtf8(line->content, (int)line->content_len);
// line->content уже содержит \n в конце
out += origin + text;
if (!text.endsWith('\n') && line->content_len > 0) out += '\n';
}
}
git_patch_free(patch);
if (out.trimmed().isEmpty())
return QStringLiteral("Нет изменений");
return out;
}
QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
{
const QString txt = fileDiffText(vaultPath, filePath, commitHash);
if (txt == QStringLiteral("Нет изменений") || txt.startsWith(QStringLiteral("Не удалось")))
return QStringLiteral("<p style='color:#888'>%1</p>").arg(escHtml(txt));
QString html;
html.reserve(txt.size()*2);
html += "<pre style='font-family:Menlo,monospace;font-size:12px;white-space:pre-wrap;word-wrap:break-word;margin:0'>";
const QStringList lines = txt.split('\n');
for (const QString &rawLine : lines) {
if (rawLine.isEmpty()) { html += "\n"; continue; }
QChar first = rawLine.at(0);
QString esc = escHtml(rawLine);
if (first == '+') {
html += QStringLiteral("<div style='background:#e6ffec;color:#1a7f37'>%1</div>").arg(esc);
} else if (first == '-') {
html += QStringLiteral("<div style='background:#ffebe9;color:#cf222e'>%1</div>").arg(esc);
} else if (first == '@') {
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
} else if (first == ' ') {
html += QStringLiteral("<div style='color:#24292f'>%1</div>").arg(esc);
} else {
html += QStringLiteral("<div style='color:#656d76'>%1</div>").arg(esc);
}
}
html += "</pre>";
return html;
}
QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash)
{
const QString vault = QDir::cleanPath(vaultPath);
const QString rel = relativePath(vaultPath, filePath);
if (vault.isEmpty() || rel.isEmpty())
return QStringLiteral("Неверные пути");
const QString content = fileContentAt(vaultPath, filePath, commitHash);
// Если файл не существовал в коммите — удаляем текущий?
// Для простоты: если контента нет, удаляем файл
const QString absPath = QDir(vault).filePath(rel);
if (content.isNull() && content.isEmpty()) {
// Проверяем был ли файл в коммите вообще
QVariantList hist = fileHistory(vaultPath, filePath);
bool found = false;
for (auto v : hist) if (v.toMap().value("hash").toString() == commitHash) { found = true; break; }
if (!found) return QStringLiteral("Коммит не найден");
// Файл не существовал — удаляем если есть
if (QFile::exists(absPath)) {
if (!QFile::remove(absPath))
return QStringLiteral("Не удалось удалить файл: %1").arg(absPath);
}
} else {
QDir().mkpath(QFileInfo(absPath).absolutePath());
QSaveFile f(absPath);
if (!f.open(QIODevice::WriteOnly))
return QStringLiteral("Не удалось записать файл: %1").arg(absPath);
f.write(content.toUtf8());
if (!f.commit())
return QStringLiteral("Не удалось сохранить файл");
}
// Коммитим восстановление
const QString shortHash = commitHash.left(7);
const QString msg = QStringLiteral("Восстановление %1 из %2").arg(QFileInfo(filePath).fileName(), shortHash);
if (!commitAll(vault, msg))
return lastError().isEmpty() ? QStringLiteral("Не удалось закоммитить восстановление") : lastError();
return QString();
}
QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const
{
const QVariantList hist = fileHistory(vaultPath, filePath);
if (!hist.isEmpty()) {
QDateTime dt = hist.first().toMap().value("time").toDateTime();
if (dt.isValid()) return dt;
}
QFileInfo fi(filePath);
if (fi.exists())
return fi.lastModified();
return QDateTime();
}
QString GitManager::lastModifiedTimeString(const QString &vaultPath, const QString &filePath) const
{
QDateTime dt = lastModifiedTime(vaultPath, filePath);
if (!dt.isValid()) return QStringLiteral("—");
// Формат как в истории
const qint64 secs = dt.secsTo(QDateTime::currentDateTime());
if (secs < 60) return QStringLiteral("только что");
if (secs < 3600) return QStringLiteral("%1 мин назад").arg(secs/60);
if (secs < 86400) return QStringLiteral("%1 ч назад").arg(secs/3600);
if (secs < 172800) return QStringLiteral("вчера %1").arg(dt.toString("hh:mm"));
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
}
bool GitManager::autoCommit(const QString &vaultPath, const QString &filePath)
{
if (vaultPath.isEmpty())
return false;
const QString fileName = QFileInfo(filePath).fileName();
const QString msg = fileName.isEmpty()
? QStringLiteral("Автосохранение")
: QStringLiteral("Автосохранение: %1").arg(fileName);
// commitAll вернёт false если нет изменений — это нормально
bool ok = commitAll(vaultPath, msg);
if (!ok && lastError() == QStringLiteral("Нет изменений для коммита")) {
setLastError(QString());
return true; // считаем успехом
}
return ok;
}