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:
@@ -127,6 +127,7 @@ qt_add_qml_module(flora
|
|||||||
qml/dialogs/NameDialog.qml
|
qml/dialogs/NameDialog.qml
|
||||||
qml/dialogs/ConfirmDialog.qml
|
qml/dialogs/ConfirmDialog.qml
|
||||||
qml/dialogs/TableEditor.qml
|
qml/dialogs/TableEditor.qml
|
||||||
|
qml/dialogs/HistoryDialog.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
# Theme как синглтон
|
# Theme как синглтон
|
||||||
|
|||||||
+41
-1
@@ -80,7 +80,29 @@ ApplicationWindow {
|
|||||||
Action { text: qsTr("Повторить"); shortcut: "Ctrl+Shift+Z"; enabled: false }
|
Action { text: qsTr("Повторить"); shortcut: "Ctrl+Shift+Z"; enabled: false }
|
||||||
MenuSeparator { }
|
MenuSeparator { }
|
||||||
Action {
|
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 {
|
Menu {
|
||||||
@@ -134,6 +156,14 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HistoryDialog {
|
||||||
|
id: historyDialog
|
||||||
|
onRestored: {
|
||||||
|
// после восстановления обновляем граф и статус
|
||||||
|
if (fileStore.ready) vaultIndex.rebuild(fileStore.vault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Popup {
|
Popup {
|
||||||
id: vaultSwitcher
|
id: vaultSwitcher
|
||||||
x: 12
|
x: 12
|
||||||
@@ -238,6 +268,11 @@ ApplicationWindow {
|
|||||||
leftPanel.showStatus(qsTr("Предпросмотр HTML/PDF/изображений появится в Фазе 6-10"), false)
|
leftPanel.showStatus(qsTr("Предпросмотр HTML/PDF/изображений появится в Фазе 6-10"), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onHistoryRequested: (path) => {
|
||||||
|
historyDialog.vaultPath = fileStore.vault
|
||||||
|
historyDialog.filePath = path
|
||||||
|
historyDialog.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorPanel {
|
EditorPanel {
|
||||||
@@ -245,6 +280,11 @@ ApplicationWindow {
|
|||||||
SplitView.fillWidth: true
|
SplitView.fillWidth: true
|
||||||
SplitView.minimumWidth: 300
|
SplitView.minimumWidth: 300
|
||||||
height: window.height - (menuBar ? menuBar.height : 0)
|
height: window.height - (menuBar ? menuBar.height : 0)
|
||||||
|
onHistoryRequested: (path) => {
|
||||||
|
historyDialog.vaultPath = fileStore.vault
|
||||||
|
historyDialog.filePath = path
|
||||||
|
historyDialog.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RightPanel {
|
RightPanel {
|
||||||
|
|||||||
@@ -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 = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Файл не выбран") + "</p>"
|
||||||
|
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 = "<p style='color:" + Theme.warningText + "'>" + qsTr("История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия.") + "</p>"
|
||||||
|
} else {
|
||||||
|
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет версий для этого файла") + "</p>"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
diffHtml = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDiff() {
|
||||||
|
if (selectedHash.length===0) {
|
||||||
|
diffHtml = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash)
|
||||||
|
if (html.length===0) diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет изменений") + "</p>"
|
||||||
|
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<documentManager.openTabs.length;++i) {
|
||||||
|
if (documentManager.pathAt(i)===root.filePath) {
|
||||||
|
// Принудительно перечитать с диска
|
||||||
|
const content = gitManager.fileContentAt(root.vaultPath, root.filePath, root.selectedHash)
|
||||||
|
// Но restore уже записал файл и закоммитил, поэтому просто переоткроем
|
||||||
|
documentManager.closeTab(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentManager.openDocument(root.filePath)
|
||||||
|
root.restored()
|
||||||
|
root.close()
|
||||||
|
} else {
|
||||||
|
errorLabel.text = err
|
||||||
|
errorPopup.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: errorPopup
|
||||||
|
title: qsTr("Ошибка")
|
||||||
|
modal: true
|
||||||
|
anchors.centerIn: parent
|
||||||
|
standardButtons: Dialog.Ok
|
||||||
|
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
||||||
|
Label { id: errorLabel; color: Theme.errorText; wrapMode: Text.Wrap; width: 360 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import Flora
|
|||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
signal historyRequested(string path)
|
||||||
|
|
||||||
property bool sideBySide: false
|
property bool sideBySide: false
|
||||||
property string viewMode: "edit" // edit, preview, live — per-tab, persisted
|
property string viewMode: "edit" // edit, preview, live — per-tab, persisted
|
||||||
property bool _loadingViewMode: false
|
property bool _loadingViewMode: false
|
||||||
@@ -411,11 +413,13 @@ Item {
|
|||||||
Timer { id: liveDebounce; interval: 150; onTriggered: { if (viewMode === "live") livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) } }
|
Timer { id: liveDebounce; interval: 150; onTriggered: { if (viewMode === "live") livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) } }
|
||||||
|
|
||||||
property string gitStatusText: ""
|
property string gitStatusText: ""
|
||||||
|
property string lastModifiedStr: ""
|
||||||
function refreshGitStatus() {
|
function refreshGitStatus() {
|
||||||
if (documentManager.activeIndex < 0 || !fileStore.ready) { gitStatusText = ""; return }
|
if (documentManager.activeIndex < 0 || !fileStore.ready) { gitStatusText = ""; lastModifiedStr = ""; return }
|
||||||
const p = documentManager.pathAt(documentManager.activeIndex)
|
const p = documentManager.pathAt(documentManager.activeIndex)
|
||||||
if (!p) { gitStatusText = ""; return }
|
if (!p) { gitStatusText = ""; lastModifiedStr = ""; return }
|
||||||
try { gitStatusText = gitManager.fileGitStatus(fileStore.vault, p) } catch(e) { gitStatusText = "" }
|
try { gitStatusText = gitManager.fileGitStatus(fileStore.vault, p) } catch(e) { gitStatusText = "" }
|
||||||
|
try { lastModifiedStr = gitManager.lastModifiedTimeString(fileStore.vault, p) } catch(e) { lastModifiedStr = "" }
|
||||||
}
|
}
|
||||||
function loadContent() {
|
function loadContent() {
|
||||||
const idx = documentManager.activeIndex
|
const idx = documentManager.activeIndex
|
||||||
@@ -458,10 +462,32 @@ Item {
|
|||||||
function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() }
|
function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() }
|
||||||
function onOpenTabsChanged() { root.loadContent() }
|
function onOpenTabsChanged() { root.loadContent() }
|
||||||
function onDirtyFlagChanged() { refreshGitStatus() }
|
function onDirtyFlagChanged() { refreshGitStatus() }
|
||||||
function onDocumentSaved() { refreshGitStatus() }
|
function onDocumentSaved(idx) {
|
||||||
|
refreshGitStatus()
|
||||||
|
// Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю
|
||||||
|
if (fileStore.ready && idx >= 0) {
|
||||||
|
const p = documentManager.pathAt(idx)
|
||||||
|
if (p && p.length>0) {
|
||||||
|
// Небольшая задержка чтобы не спамить на каждый автосейв
|
||||||
|
autoCommitDebounce.filePath = p
|
||||||
|
autoCommitDebounce.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
|
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()
|
Component.onCompleted: loadContent()
|
||||||
|
|
||||||
function applyInline(pre, post) {
|
function applyInline(pre, post) {
|
||||||
@@ -578,7 +604,27 @@ Item {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
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 }
|
ButtonGroup { id: viewGroup }
|
||||||
ToolButton {
|
ToolButton {
|
||||||
text: "✎"
|
text: "✎"
|
||||||
@@ -627,6 +673,10 @@ Item {
|
|||||||
|
|
||||||
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
|
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
|
||||||
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
|
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: {
|
Shortcut { sequence: "Ctrl+E"; onActivated: {
|
||||||
if (viewMode === "edit") {
|
if (viewMode === "edit") {
|
||||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Item {
|
|||||||
|
|
||||||
signal openVaultRequested()
|
signal openVaultRequested()
|
||||||
signal openFileRequested(string path)
|
signal openFileRequested(string path)
|
||||||
|
signal historyRequested(string path)
|
||||||
|
|
||||||
function baseName(p) {
|
function baseName(p) {
|
||||||
const idx = p.lastIndexOf("/")
|
const idx = p.lastIndexOf("/")
|
||||||
@@ -415,6 +416,12 @@ Item {
|
|||||||
deleteDialog.open()
|
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<n;++i) if (fileStore.itemPath(i)===p) return i; return -1 }
|
function rowIndexOf(p) { const n = fileStore.rowCount(); for (let i=0;i<n;++i) if (fileStore.itemPath(i)===p) return i; return -1 }
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
#include "git/GitManager.h"
|
#include "git/GitManager.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QTimeZone>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
namespace {
|
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
|
} // namespace
|
||||||
|
|
||||||
bool GitManager::s_gitInitialized = false;
|
bool GitManager::s_gitInitialized = false;
|
||||||
@@ -73,6 +106,54 @@ void GitManager::setLastError(const QString &msg)
|
|||||||
emit lastErrorChanged();
|
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)
|
bool GitManager::initRepo(const QString &path)
|
||||||
{
|
{
|
||||||
const QString clean = QDir::cleanPath(path.trimmed());
|
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) {
|
if (git_repository_open(&repo, p.constData()) == 0) {
|
||||||
setRepoIdentity(repo);
|
setRepoIdentity(repo);
|
||||||
git_repository_free(repo);
|
git_repository_free(repo);
|
||||||
|
ensureGitignore(clean);
|
||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
return true; // .git уже есть
|
return true; // .git уже есть
|
||||||
}
|
}
|
||||||
@@ -102,6 +184,7 @@ bool GitManager::initRepo(const QString &path)
|
|||||||
}
|
}
|
||||||
setRepoIdentity(repo);
|
setRepoIdentity(repo);
|
||||||
git_repository_free(repo);
|
git_repository_free(repo);
|
||||||
|
ensureGitignore(clean);
|
||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -270,3 +353,319 @@ bool GitManager::commitAll(const QString &path, const QString &message)
|
|||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
+27
-1
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
#include <git2.h>
|
#include <git2.h>
|
||||||
|
|
||||||
// Обёртка над libgit2: инициализация репозитория хранилища и авто-коммиты.
|
// Обёртка над libgit2: инициализация репозитория хранилища, авто-коммиты и история.
|
||||||
class GitManager : public QObject
|
class GitManager : public QObject
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@@ -29,6 +31,28 @@ public:
|
|||||||
// Короткий статус для отображения в UI: "сохранено" / "изменён" / "новый" etc
|
// Короткий статус для отображения в UI: "сохранено" / "изменён" / "новый" etc
|
||||||
Q_INVOKABLE QString fileGitStatusShort(const QString &vaultPath, const QString &filePath) const;
|
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)
|
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
||||||
|
|
||||||
@@ -42,6 +66,8 @@ signals:
|
|||||||
private:
|
private:
|
||||||
void setRepoIdentity(git_repository *repo);
|
void setRepoIdentity(git_repository *repo);
|
||||||
void setLastError(const QString &msg);
|
void setLastError(const QString &msg);
|
||||||
|
QString relativePath(const QString &vaultPath, const QString &filePath) const;
|
||||||
|
void ensureGitignore(const QString &vaultPath);
|
||||||
|
|
||||||
QString m_lastError;
|
QString m_lastError;
|
||||||
static bool s_gitInitialized;
|
static bool s_gitInitialized;
|
||||||
|
|||||||
@@ -152,3 +152,12 @@ if(TARGET KF6SyntaxHighlighting)
|
|||||||
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
|
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME flora_integration_test COMMAND flora_integration_test)
|
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)
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Тест GitManager истории: вперёд/назад, diff, восстановление, время
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QThread>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user