diff --git a/CMakeLists.txt b/CMakeLists.txt
index e8a660a..efb6863 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -127,6 +127,7 @@ qt_add_qml_module(flora
qml/dialogs/NameDialog.qml
qml/dialogs/ConfirmDialog.qml
qml/dialogs/TableEditor.qml
+ qml/dialogs/HistoryDialog.qml
)
# Theme как синглтон
diff --git a/qml/MainWindow.qml b/qml/MainWindow.qml
index a199b5d..e4fee90 100644
--- a/qml/MainWindow.qml
+++ b/qml/MainWindow.qml
@@ -80,7 +80,29 @@ ApplicationWindow {
Action { text: qsTr("Повторить"); shortcut: "Ctrl+Shift+Z"; enabled: false }
MenuSeparator { }
Action {
- text: qsTr("Вернуться к предыдущей версии…"); enabled: false
+ text: qsTr("История файла…")
+ shortcut: "Ctrl+H"
+ enabled: documentManager.activeIndex >= 0 && fileStore.ready
+ onTriggered: {
+ const p = documentManager.pathAt(documentManager.activeIndex)
+ if (p.length>0) {
+ historyDialog.vaultPath = fileStore.vault
+ historyDialog.filePath = p
+ historyDialog.open()
+ }
+ }
+ }
+ Action {
+ text: qsTr("Вернуться к предыдущей версии…")
+ enabled: documentManager.activeIndex >= 0 && fileStore.ready
+ onTriggered: {
+ const p = documentManager.pathAt(documentManager.activeIndex)
+ if (p.length>0) {
+ historyDialog.vaultPath = fileStore.vault
+ historyDialog.filePath = p
+ historyDialog.open()
+ }
+ }
}
}
Menu {
@@ -134,6 +156,14 @@ ApplicationWindow {
}
}
+ HistoryDialog {
+ id: historyDialog
+ onRestored: {
+ // после восстановления обновляем граф и статус
+ if (fileStore.ready) vaultIndex.rebuild(fileStore.vault)
+ }
+ }
+
Popup {
id: vaultSwitcher
x: 12
@@ -238,6 +268,11 @@ ApplicationWindow {
leftPanel.showStatus(qsTr("Предпросмотр HTML/PDF/изображений появится в Фазе 6-10"), false)
}
}
+ onHistoryRequested: (path) => {
+ historyDialog.vaultPath = fileStore.vault
+ historyDialog.filePath = path
+ historyDialog.open()
+ }
}
EditorPanel {
@@ -245,6 +280,11 @@ ApplicationWindow {
SplitView.fillWidth: true
SplitView.minimumWidth: 300
height: window.height - (menuBar ? menuBar.height : 0)
+ onHistoryRequested: (path) => {
+ historyDialog.vaultPath = fileStore.vault
+ historyDialog.filePath = path
+ historyDialog.open()
+ }
}
RightPanel {
diff --git a/qml/dialogs/HistoryDialog.qml b/qml/dialogs/HistoryDialog.qml
new file mode 100644
index 0000000..f21d9de
--- /dev/null
+++ b/qml/dialogs/HistoryDialog.qml
@@ -0,0 +1,439 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Flora
+
+Dialog {
+ id: root
+
+ property string vaultPath: ""
+ property string filePath: ""
+ property string fileName: {
+ const p = filePath
+ const i = p.lastIndexOf("/")
+ return i >= 0 ? p.substring(i+1) : p
+ }
+
+ signal restored()
+
+ title: qsTr("История: %1").arg(fileName.length>0 ? fileName : qsTr("файл"))
+ modal: true
+ width: 900
+ height: 640
+ anchors.centerIn: parent
+ closePolicy: Dialog.NoAutoClose
+ standardButtons: Dialog.NoButton
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
+
+ property var historyModel: []
+ property int selectedIndex: -1
+ property string selectedHash: ""
+ property string diffHtml: ""
+ property bool busy: false
+
+ function reload() {
+ if (vaultPath.length===0 || filePath.length===0) {
+ historyModel = []
+ diffHtml = "
" + qsTr("Файл не выбран") + "
"
+ return
+ }
+ busy = true
+ // берём историю
+ const hist = gitManager.fileHistory(vaultPath, filePath)
+ historyModel = hist
+ listView.model = hist
+ if (hist.length>0) {
+ selectedIndex = 0
+ selectedHash = hist[0].hash
+ updateDiff()
+ } else {
+ selectedIndex = -1
+ selectedHash = ""
+ const hasFile = filePath.length>0
+ if (hasFile) {
+ const st = gitManager.fileGitStatus(vaultPath, filePath)
+ if (st === "неотслеживается" || st === "новый") {
+ diffHtml = "" + qsTr("История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия.") + "
"
+ } else {
+ diffHtml = "" + qsTr("Нет версий для этого файла") + "
"
+ }
+ } else {
+ diffHtml = ""
+ }
+ }
+ busy = false
+ }
+
+ function updateDiff() {
+ if (selectedHash.length===0) {
+ diffHtml = ""
+ return
+ }
+ const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash)
+ if (html.length===0) diffHtml = "" + qsTr("Нет изменений") + "
"
+ else diffHtml = html
+ // также обновляем превью содержимого версии
+ const content = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
+ previewArea.text = content
+ }
+
+ onOpened: reload()
+ onVaultPathChanged: if (visible) reload()
+ onFilePathChanged: if (visible) reload()
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 10
+
+ // Заголовок с информацией о файле
+ Rectangle {
+ Layout.fillWidth: true
+ color: Theme.bgAlt
+ radius: 8
+ border.color: Theme.border
+ height: infoColumn.implicitHeight + 16
+ ColumnLayout {
+ id: infoColumn
+ anchors.fill: parent
+ anchors.margins: 8
+ spacing: 4
+ Label {
+ text: root.filePath.length>0 ? root.filePath : qsTr("—")
+ color: Theme.textMuted
+ elide: Text.ElideMiddle
+ Layout.fillWidth: true
+ font.pixelSize: 11
+ }
+ RowLayout {
+ Layout.fillWidth: true
+ Label {
+ text: qsTr("Последнее изменение:")
+ color: Theme.textSecondary
+ font.pixelSize: 11
+ font.bold: true
+ }
+ Label {
+ text: {
+ if (root.filePath.length===0) return "—"
+ const s = gitManager.lastModifiedTimeString(root.vaultPath, root.filePath)
+ return s.length>0 ? s : "—"
+ }
+ color: Theme.text
+ font.pixelSize: 11
+ }
+ Item { Layout.fillWidth: true }
+ Label {
+ text: historyModel.length + " " + (historyModel.length===1 ? qsTr("версия") : (historyModel.length>=2 && historyModel.length<=4 ? qsTr("версии") : qsTr("версий")))
+ color: Theme.textFaint
+ font.pixelSize: 11
+ }
+ BusyIndicator { visible: root.busy; running: root.busy; implicitWidth: 16; implicitHeight: 16 }
+ }
+ Label {
+ visible: historyModel.length===0
+ text: qsTr("Подсказка: каждый раз когда вы сохраняете файл, Flora автоматически сохраняет версию. Здесь можно вернуться к любой из них.")
+ color: Theme.textFaint
+ font.pixelSize: 11
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
+ }
+ }
+ }
+
+ SplitView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ orientation: Qt.Horizontal
+ handle: Rectangle { implicitWidth: 1; color: Theme.divider }
+
+ // Левая панель — список версий
+ Rectangle {
+ SplitView.preferredWidth: 280
+ SplitView.minimumWidth: 200
+ SplitView.maximumWidth: 380
+ color: Theme.bg
+ border.color: Theme.divider
+ radius: 8
+ clip: true
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 6
+ spacing: 6
+ Label {
+ text: qsTr("Версии")
+ font.bold: true
+ color: Theme.textSecondary
+ font.pixelSize: 12
+ }
+ Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
+ ListView {
+ id: listView
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ ScrollBar.vertical: ScrollBar {}
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+ width: ListView.view.width
+ height: 72
+ radius: 6
+ color: index === root.selectedIndex ? Theme.surfaceSelected : (hover.hovered ? Theme.surfaceHover : "transparent")
+ border.color: index === root.selectedIndex ? Theme.accent : "transparent"
+ border.width: 1
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 8
+ spacing: 2
+ RowLayout {
+ Layout.fillWidth: true
+ Label {
+ text: modelData.timeAgo || ""
+ font.bold: true
+ color: index === root.selectedIndex ? Theme.text : Theme.text
+ font.pixelSize: 12
+ }
+ Item { Layout.fillWidth: true }
+ Label {
+ text: modelData.shortHash || ""
+ color: Theme.textFaint
+ font.pixelSize: 10
+ font.family: "Menlo, monospace"
+ }
+ }
+ Label {
+ text: modelData.timeStr || ""
+ color: Theme.textMuted
+ font.pixelSize: 10
+ }
+ Label {
+ text: modelData.message || ""
+ color: Theme.textSecondary
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ }
+ Label {
+ text: modelData.author || ""
+ color: Theme.textFaint
+ font.pixelSize: 10
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ }
+ }
+ HoverHandler { id: hover }
+ TapHandler {
+ onTapped: {
+ root.selectedIndex = index
+ root.selectedHash = modelData.hash
+ root.updateDiff()
+ }
+ }
+ }
+ Label {
+ anchors.centerIn: parent
+ visible: listView.count===0
+ text: qsTr("Нет истории\nСохраните файл чтобы создать первую версию")
+ color: Theme.textMuted
+ horizontalAlignment: Text.AlignHCenter
+ font.pixelSize: 11
+ }
+ }
+ RowLayout {
+ Layout.fillWidth: true
+ Button {
+ text: qsTr("Обновить")
+ onClicked: root.reload()
+ }
+ Item { Layout.fillWidth: true }
+ Label {
+ visible: root.selectedHash.length>0
+ text: qsTr("Выбрана: %1").arg(root.selectedHash.substring(0,7))
+ color: Theme.textFaint
+ font.pixelSize: 10
+ font.family: "Menlo, monospace"
+ }
+ }
+ }
+ }
+
+ // Правая панель — diff и превью
+ ColumnLayout {
+ SplitView.fillWidth: true
+ SplitView.minimumWidth: 300
+ spacing: 0
+
+ TabBar {
+ id: tabBar
+ Layout.fillWidth: true
+ TabButton { text: qsTr("Изменения") }
+ TabButton { text: qsTr("Содержимое версии") }
+ }
+
+ StackLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ currentIndex: tabBar.currentIndex
+
+ // Diff вкладка
+ Rectangle {
+ color: Theme.editorBg
+ border.color: Theme.divider
+ ScrollView {
+ anchors.fill: parent
+ anchors.margins: 6
+ clip: true
+ ScrollBar.vertical: ScrollBar {}
+ TextArea {
+ id: diffArea
+ readOnly: true
+ textFormat: Text.RichText
+ wrapMode: Text.Wrap
+ color: Theme.text
+ selectionColor: Theme.selectionBg
+ text: root.diffHtml
+ background: Rectangle { color: Theme.editorBg }
+ font.family: "Menlo, monospace"
+ font.pixelSize: 12
+ }
+ }
+ }
+
+ // Preview вкладка
+ Rectangle {
+ color: Theme.editorBg
+ border.color: Theme.divider
+ ScrollView {
+ anchors.fill: parent
+ anchors.margins: 6
+ clip: true
+ ScrollBar.vertical: ScrollBar {}
+ TextArea {
+ id: previewArea
+ readOnly: true
+ wrapMode: Text.Wrap
+ color: Theme.text
+ selectionColor: Theme.selectionBg
+ background: Rectangle { color: Theme.editorBg }
+ font.family: "Menlo, monospace"
+ font.pixelSize: 12
+ }
+ }
+ }
+ }
+
+ // Легенда
+ Row {
+ Layout.fillWidth: true
+ Layout.margins: 6
+ spacing: 12
+ Row { spacing: 4; Rectangle { width:12; height:12; color:"#e6ffec"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } }
+ Row { spacing: 4; Rectangle { width:12; height:12; color:"#ffebe9"; border.color:"#cf222e"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } }
+ Row { spacing: 4; Rectangle { width:12; height:12; color:"#ddf4ff"; border.color:"#0969da"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("заголовок"); color: Theme.textMuted; font.pixelSize:10 } }
+ Label {
+ text: qsTr("Сравнение выбранной версии с текущим файлом")
+ color: Theme.textFaint
+ font.pixelSize: 10
+ Layout.fillWidth: true
+ horizontalAlignment: Text.AlignRight
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+
+ // Нижняя панель кнопок
+ RowLayout {
+ Layout.fillWidth: true
+ Button {
+ text: qsTr("Закрыть")
+ onClicked: root.close()
+ }
+ Item { Layout.fillWidth: true }
+ Label {
+ visible: root.selectedHash.length>0
+ text: qsTr("Выбрав версию, нажмите «Восстановить» чтобы вернуть файл к этому состоянию. Будет создан новый коммит, и вы сможете снова откатиться вперёд.")
+ color: Theme.textFaint
+ font.pixelSize: 10
+ wrapMode: Text.Wrap
+ Layout.preferredWidth: 360
+ }
+ Button {
+ text: qsTr("Восстановить эту версию")
+ enabled: root.selectedHash.length>0
+ highlighted: true
+ onClicked: confirmRestore.open()
+ }
+ }
+ }
+
+ // Подтверждение восстановления
+ Dialog {
+ id: confirmRestore
+ title: qsTr("Восстановить версию?")
+ modal: true
+ anchors.centerIn: parent
+ width: 420
+ standardButtons: Dialog.NoButton
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 12
+ Label {
+ text: qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.")
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
+ color: 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 : "")
+ color: Theme.textMuted
+ font.pixelSize: 11
+ Layout.fillWidth: true
+ }
+ RowLayout {
+ Layout.fillWidth: true
+ Item { Layout.fillWidth: true }
+ Button { text: qsTr("Отмена"); onClicked: confirmRestore.close() }
+ Button {
+ text: qsTr("Восстановить")
+ highlighted: true
+ onClicked: {
+ confirmRestore.close()
+ const err = gitManager.restoreFile(root.vaultPath, root.filePath, root.selectedHash)
+ if (err.length===0) {
+ // Перезагружаем документ в редакторе
+ // Находим индекс вкладки и перезагружаем содержимое
+ for (let i=0;i= 0) {
+ const p = documentManager.pathAt(idx)
+ if (p && p.length>0) {
+ // Небольшая задержка чтобы не спамить на каждый автосейв
+ autoCommitDebounce.filePath = p
+ autoCommitDebounce.restart()
+ }
+ }
+ }
}
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
- Timer { id: gitPoll; interval: 2000; running: documentManager.activeIndex >= 0; repeat: true; onTriggered: refreshGitStatus() }
+ Timer { id: gitPoll; interval: 3000; running: documentManager.activeIndex >= 0; repeat: true; onTriggered: refreshGitStatus() }
+ Timer {
+ id: autoCommitDebounce
+ property string filePath: ""
+ interval: 2000
+ onTriggered: {
+ if (fileStore.ready && filePath.length>0) {
+ gitManager.autoCommit(fileStore.vault, filePath)
+ refreshGitStatus()
+ }
+ }
+ }
Component.onCompleted: loadContent()
function applyInline(pre, post) {
@@ -578,7 +604,27 @@ Item {
anchors.verticalCenter: parent.verticalCenter
}
}
- Item { width: 8 }
+ ToolButton {
+ text: "🕘"
+ enabled: documentManager.activeIndex >= 0
+ ToolTip.text: lastModifiedStr.length>0 ? qsTr("История — последнее изменение: %1").arg(lastModifiedStr) : qsTr("История файла (Ctrl+H)")
+ ToolTip.visible: hovered
+ onClicked: {
+ const p = documentManager.pathAt(documentManager.activeIndex)
+ if (p) root.historyRequested(p)
+ }
+ }
+ Label {
+ visible: lastModifiedStr.length>0 && documentManager.activeIndex>=0
+ text: lastModifiedStr
+ color: Theme.textMuted
+ font.pixelSize: 10
+ Layout.alignment: Qt.AlignVCenter
+ ToolTip.text: qsTr("Последнее изменение")
+ HoverHandler { id: lastModHover }
+ ToolTip.visible: lastModHover.hovered
+ }
+ Item { width: 4 }
ButtonGroup { id: viewGroup }
ToolButton {
text: "✎"
@@ -627,6 +673,10 @@ Item {
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
+ Shortcut { sequence: "Ctrl+H"; onActivated: {
+ const p = documentManager.pathAt(documentManager.activeIndex)
+ if (p) root.historyRequested(p)
+ } }
Shortcut { sequence: "Ctrl+E"; onActivated: {
if (viewMode === "edit") {
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
diff --git a/qml/panels/FileTreePanel.qml b/qml/panels/FileTreePanel.qml
index 066fabd..9ab2520 100644
--- a/qml/panels/FileTreePanel.qml
+++ b/qml/panels/FileTreePanel.qml
@@ -8,6 +8,7 @@ Item {
signal openVaultRequested()
signal openFileRequested(string path)
+ signal historyRequested(string path)
function baseName(p) {
const idx = p.lastIndexOf("/")
@@ -415,6 +416,12 @@ Item {
deleteDialog.open()
}
}
+ MenuSeparator { visible: !rowMenu.targetIsDir }
+ MenuItem {
+ text: qsTr("История…")
+ visible: !rowMenu.targetIsDir
+ onClicked: root.historyRequested(rowMenu.targetPath)
+ }
}
function rowIndexOf(p) { const n = fileStore.rowCount(); for (let i=0;i
#include
#include
+#include
+#include
+#include
+#include
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 += "&"; break;
+ case '<': out += "<"; break;
+ case '>': out += ">"; break;
+ case '"': out += """; break;
+ case '\'': out += "'"; 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("%1
").arg(escHtml(txt));
+
+ QString html;
+ html.reserve(txt.size()*2);
+ html += "";
+ 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("%1
").arg(esc);
+ } else if (first == '-') {
+ html += QStringLiteral("%1
").arg(esc);
+ } else if (first == '@') {
+ html += QStringLiteral("%1
").arg(esc);
+ } else if (first == ' ') {
+ html += QStringLiteral("%1
").arg(esc);
+ } else {
+ html += QStringLiteral("%1
").arg(esc);
+ }
+ }
+ html += "";
+ 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;
}
\ No newline at end of file
diff --git a/src/git/GitManager.h b/src/git/GitManager.h
index b3aa893..52cf689 100644
--- a/src/git/GitManager.h
+++ b/src/git/GitManager.h
@@ -2,10 +2,12 @@
#include
#include
+#include
+#include
#include
-// Обёртка над libgit2: инициализация репозитория хранилища и авто-коммиты.
+// Обёртка над libgit2: инициализация репозитория хранилища, авто-коммиты и история.
class GitManager : public QObject
{
Q_OBJECT
@@ -29,6 +31,28 @@ public:
// Короткий статус для отображения в UI: "сохранено" / "изменён" / "новый" etc
Q_INVOKABLE QString fileGitStatusShort(const QString &vaultPath, const QString &filePath) const;
+ // История файла: список коммитов где файл менялся. Сортировка новые сверху.
+ // Каждый элемент: {hash, shortHash, message, author, time (QDateTime), timeStr, timeAgo}
+ Q_INVOKABLE QVariantList fileHistory(const QString &vaultPath, const QString &filePath) const;
+
+ // Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал.
+ Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
+
+ // Diff между версией в коммите и текущим файлом (рабочее дерево). Возвращает HTML с цветами.
+ Q_INVOKABLE QString fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
+ // Текстовый diff (unified)
+ Q_INVOKABLE QString fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
+
+ // Восстановить файл из коммита (перезаписать и закоммитить). Возвращает "" при успехе или текст ошибки.
+ Q_INVOKABLE QString restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash);
+
+ // Время последнего изменения файла (по git, или mtime если не в git). Для отображения пользователю.
+ Q_INVOKABLE QDateTime lastModifiedTime(const QString &vaultPath, const QString &filePath) const;
+ Q_INVOKABLE QString lastModifiedTimeString(const QString &vaultPath, const QString &filePath) const;
+
+ // Авто-коммит (для DocumentManager). Сообщение генерируется автоматически.
+ Q_INVOKABLE bool autoCommit(const QString &vaultPath, const QString &filePath);
+
// Последнее сообщение об ошибке (русскоязычное, из каталога ошибок).
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
@@ -42,6 +66,8 @@ signals:
private:
void setRepoIdentity(git_repository *repo);
void setLastError(const QString &msg);
+ QString relativePath(const QString &vaultPath, const QString &filePath) const;
+ void ensureGitignore(const QString &vaultPath);
QString m_lastError;
static bool s_gitInitialized;
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 30a55b4..3338f80 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -151,4 +151,13 @@ target_link_libraries(flora_integration_test PRIVATE Qt6::Core Qt6::Gui cmark::c
if(TARGET KF6SyntaxHighlighting)
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
endif()
-add_test(NAME flora_integration_test COMMAND flora_integration_test)
\ No newline at end of file
+add_test(NAME flora_integration_test COMMAND flora_integration_test)
+
+# Тест истории Git (вперёд/назад, diff, восстановление, время)
+add_executable(flora_git_history_test git_history_test.cpp
+ ../src/git/GitManager.cpp
+ ../src/git/GitManager.h
+)
+target_include_directories(flora_git_history_test PRIVATE ../src)
+target_link_libraries(flora_git_history_test PRIVATE Qt6::Core Flora_libgit2_static)
+add_test(NAME flora_git_history_test COMMAND flora_git_history_test)
\ No newline at end of file
diff --git a/tests/git_history_test.cpp b/tests/git_history_test.cpp
new file mode 100644
index 0000000..8167328
--- /dev/null
+++ b/tests/git_history_test.cpp
@@ -0,0 +1,179 @@
+// Тест GitManager истории: вперёд/назад, diff, восстановление, время
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "git/GitManager.h"
+
+static int fail(const char *msg, const QString &detail=QString()){
+ std::fprintf(stderr,"FAIL: %s | %s\n", msg, qPrintable(detail));
+ return 1;
+}
+
+int main(int argc, char *argv[])
+{
+ QCoreApplication app(argc, argv);
+
+ QTemporaryDir dir;
+ if(!dir.isValid()) return fail("tmp dir");
+ QString vault = dir.path();
+ GitManager git;
+
+ if(!git.initRepo(vault)) return fail("initRepo", git.lastError());
+ // должен создать .gitignore
+ if(!QFile::exists(vault+"/.gitignore")) return fail(".gitignore not created");
+ QFile ig(vault+"/.gitignore");
+ ig.open(QIODevice::ReadOnly);
+ QString igContent = QString::fromUtf8(ig.readAll());
+ if(!igContent.contains(".flora/")) return fail(".gitignore missing .flora", igContent);
+
+ // .flora должен игнорироваться
+ QDir().mkpath(vault+"/.flora");
+ QFile hidden(vault+"/.flora/test"); hidden.open(QIODevice::WriteOnly); hidden.write("x"); hidden.close();
+ // статус hidden должен быть игнорируется
+ QString st = git.fileGitStatus(vault, vault+"/.flora/test");
+ if(st != "игнорируется" && st != "—") {
+ // допустимо, но должен быть игнорируется после коммита .gitignore?
+ // Сначала закоммитим .gitignore
+ }
+
+ // Создаём файл v1
+ QString file = vault+"/note.md";
+ QFile f(file); f.open(QIODevice::WriteOnly); f.write("Версия 1\nСтрока A\n"); f.close();
+ if(!git.commitAll(vault, "v1")) return fail("commit v1", git.lastError());
+ if(!git.hasCommits(vault)) return fail("hasCommits");
+
+ // История после v1: 1 элемент
+ QVariantList hist = git.fileHistory(vault, file);
+ if(hist.size()!=1) return fail("hist size 1", QString::number(hist.size()));
+ QString hash1 = hist.first().toMap().value("hash").toString();
+ QString short1 = hist.first().toMap().value("shortHash").toString();
+ if(hash1.isEmpty() || short1.isEmpty()) return fail("hash empty");
+ if(hist.first().toMap().value("message").toString()!="v1") return fail("message v1");
+ QDateTime t1 = hist.first().toMap().value("time").toDateTime();
+ if(!t1.isValid()) return fail("time invalid");
+ if(hist.first().toMap().value("timeAgo").toString().isEmpty()) return fail("timeAgo empty");
+ // lastModified
+ QDateTime lm = git.lastModifiedTime(vault, file);
+ if(!lm.isValid()) return fail("lastModified invalid");
+ QString lmStr = git.lastModifiedTimeString(vault, file);
+ if(lmStr.isEmpty() || lmStr=="—") return fail("lmStr empty", lmStr);
+ // content at
+ QString c1 = git.fileContentAt(vault, file, hash1);
+ if(!c1.contains("Версия 1")) return fail("content v1", c1);
+ // diff с текущим (одинаково) должен быть "Нет изменений" или пусто?
+ QString diffSame = git.fileDiffText(vault, file, hash1);
+ if(diffSame != "Нет изменений" && !diffSame.isEmpty()) {
+ // если файл не менялся, diff должен быть "Нет изменений"
+ // Но мы ещё не меняли, так что должно быть нет изменений
+ if(!diffSame.contains("Нет изменений"))
+ return fail("diff same should be нет изменений", diffSame);
+ }
+
+ // Изменяем файл → v2
+ QThread::msleep(1100); // чтобы время коммита отличалось
+ QFile f2(file); f2.open(QIODevice::WriteOnly | QIODevice::Truncate); f2.write("Версия 2\nСтрока A\nСтрока B\n"); f2.close();
+ if(!git.commitAll(vault, "v2 - добавили строку B")) return fail("commit v2", git.lastError());
+ QVariantList hist2 = git.fileHistory(vault, file);
+ if(hist2.size()!=2) return fail("hist size 2", QString::number(hist2.size()));
+ // Новый сверху
+ QString hash2 = hist2.first().toMap().value("hash").toString();
+ if(hash2==hash1) return fail("hash2 same as hash1");
+ if(hist2.first().toMap().value("message").toString().left(2)!="v2") return fail("hist2 top message", hist2.first().toMap().value("message").toString());
+ if(hist2.last().toMap().value("hash").toString()!=hash1) return fail("hist2 last not hash1");
+ // время второго должно быть позже
+ QDateTime t2 = hist2.first().toMap().value("time").toDateTime();
+ if(t2 <= t1) return fail("t2 not later", t2.toString() + " vs " + t1.toString());
+ // content at hash2
+ QString c2 = git.fileContentAt(vault, file, hash2);
+ if(!c2.contains("Версия 2") || !c2.contains("Строка B")) return fail("c2 content", c2);
+ // content at hash1 всё ещё v1
+ QString c1again = git.fileContentAt(vault, file, hash1);
+ if(!c1again.contains("Версия 1") || c1again.contains("Строка B")) return fail("c1again", c1again);
+ // diff hash1 vs current (v2): должен показать + строка B и -Версия1 +Версия2
+ QString diff = git.fileDiffText(vault, file, hash1);
+ if(!diff.contains("+") || !diff.contains("-")) return fail("diff should have + -", diff);
+ if(!diff.contains("Версия 1") || !diff.contains("Версия 2")) return fail("diff contains versions", diff);
+ QString diffHtml = git.fileDiffHtml(vault, file, hash1);
+ if(!diffHtml.contains("background:#e6ffec") || !diffHtml.contains("background:#ffebe9")) return fail("diffHtml colors", diffHtml.left(500));
+
+ // forward/backward: добавляем ещё файл other.md, коммитим, история note.md не должна вырасти
+ QString other = vault+"/other.md";
+ QFile of(other); of.open(QIODevice::WriteOnly); of.write("other\n"); of.close();
+ if(!git.commitAll(vault, "add other")) return fail("commit other");
+ QVariantList histAfterOther = git.fileHistory(vault, file);
+ if(histAfterOther.size()!=2) return fail("hist after other should still 2", QString::number(histAfterOther.size()));
+
+ // Восстановление назад к v1
+ QString err = git.restoreFile(vault, file, hash1);
+ if(!err.isEmpty()) return fail("restore to v1", err);
+ QFile check(file); check.open(QIODevice::ReadOnly); QString cur = QString::fromUtf8(check.readAll());
+ if(!cur.contains("Версия 1") || cur.contains("Строка B")) return fail("after restore v1 content", cur);
+ // История после восстановления должна иметь 3 элемента (v1, v2, восстановление)
+ QVariantList hist3 = git.fileHistory(vault, file);
+ if(hist3.size()!=3) return fail("hist after restore size 3", QString::number(hist3.size()));
+ if(!hist3.first().toMap().value("message").toString().contains("Восстановление")) return fail("restore commit message", hist3.first().toMap().value("message").toString());
+ // lastModified должно быть сейчас (только что)
+ QString lmAfter = git.lastModifiedTimeString(vault, file);
+ if(!lmAfter.contains("только что") && !lmAfter.contains("мин назад") && !lmAfter.contains("ч назад")) {
+ // допустимо, но должно быть свежим
+ }
+
+ // Восстановление вперёд к v2 (используем hash2)
+ err = git.restoreFile(vault, file, hash2);
+ if(!err.isEmpty()) return fail("restore forward to v2", err);
+ QFile check2(file); check2.open(QIODevice::ReadOnly); QString cur2 = QString::fromUtf8(check2.readAll());
+ if(!cur2.contains("Версия 2")) return fail("after restore forward v2", cur2);
+ QVariantList hist4 = git.fileHistory(vault, file);
+ if(hist4.size()!=4) return fail("hist after forward restore 4", QString::number(hist4.size()));
+
+ // Ещё одно изменение вручную вперёд
+ QThread::msleep(1100);
+ QFile f3(file); f3.open(QIODevice::WriteOnly | QIODevice::Truncate); f3.write("Версия 3\nНовая строка\n"); f3.close();
+ if(!git.commitAll(vault, "v3")) return fail("commit v3");
+ QVariantList hist5 = git.fileHistory(vault, file);
+ if(hist5.size()!=5) return fail("hist 5", QString::number(hist5.size()));
+ if(hist5.first().toMap().value("message").toString()!="v3") return fail("top v3");
+
+ // Проверка что diff между v1 и текущим (v3) показывает изменения
+ QString diffV1Now = git.fileDiffText(vault, file, hash1);
+ if(!diffV1Now.contains("Версия 1") || !diffV1Now.contains("Версия 3")) return fail("diff v1->v3", diffV1Now);
+
+ // Проверка несуществующего коммита
+ QString badContent = git.fileContentAt(vault, file, "0000000000000000000000000000000000000000");
+ if(!badContent.isEmpty()) return fail("bad hash should return empty");
+
+ QString badDiff = git.fileDiffText(vault, file, "0000000000000000000000000000000000000000");
+ // должен либо вернуть ошибку diff, либо содержать old empty
+ // не проверяем строго
+
+ QString badRestore = git.restoreFile(vault, file, "0000000000000000000000000000000000000000");
+ if(badRestore.isEmpty()) return fail("bad restore should fail");
+
+ // Проверка lastModified для несуществующего файла
+ QDateTime lmBad = git.lastModifiedTime(vault, vault+"/nonexistent.md");
+ if(lmBad.isValid()) {
+ // если файл не существует, должен быть невалидным или mtime? Сейчас возвращает невалидный
+ // допустимо
+ }
+
+ // Авто-коммит
+ QFile f4(file); f4.open(QIODevice::WriteOnly | QIODevice::Append); f4.write("\nauto\n"); f4.close();
+ bool autoOk = git.autoCommit(vault, file);
+ if(!autoOk) return fail("autoCommit", git.lastError());
+ QVariantList histAuto = git.fileHistory(vault, file);
+ if(histAuto.size()!=6) return fail("hist after auto 6", QString::number(histAuto.size()));
+ if(!histAuto.first().toMap().value("message").toString().contains("Автосохранение")) return fail("auto message", histAuto.first().toMap().value("message").toString());
+
+ // Повторный autoCommit без изменений должен быть true (нет ошибки)
+ bool autoAgain = git.autoCommit(vault, file);
+ if(!autoAgain) return fail("auto again should be true even if no changes");
+
+ std::printf("GIT HISTORY TEST OK (forward/backward, diff, restore, time)\n");
+ return 0;
+}