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; +}