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/ConfirmDialog.qml
|
||||
qml/dialogs/TableEditor.qml
|
||||
qml/dialogs/HistoryDialog.qml
|
||||
)
|
||||
|
||||
# Theme как синглтон
|
||||
|
||||
+41
-1
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
id: root
|
||||
|
||||
signal historyRequested(string path)
|
||||
|
||||
property bool sideBySide: false
|
||||
property string viewMode: "edit" // edit, preview, live — per-tab, persisted
|
||||
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()) } }
|
||||
|
||||
property string gitStatusText: ""
|
||||
property string lastModifiedStr: ""
|
||||
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)
|
||||
if (!p) { gitStatusText = ""; return }
|
||||
if (!p) { gitStatusText = ""; lastModifiedStr = ""; return }
|
||||
try { gitStatusText = gitManager.fileGitStatus(fileStore.vault, p) } catch(e) { gitStatusText = "" }
|
||||
try { lastModifiedStr = gitManager.lastModifiedTimeString(fileStore.vault, p) } catch(e) { lastModifiedStr = "" }
|
||||
}
|
||||
function loadContent() {
|
||||
const idx = documentManager.activeIndex
|
||||
@@ -458,10 +462,32 @@ Item {
|
||||
function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() }
|
||||
function onOpenTabsChanged() { root.loadContent() }
|
||||
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() } }
|
||||
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())
|
||||
|
||||
@@ -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<n;++i) if (fileStore.itemPath(i)===p) return i; return -1 }
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#include "git/GitManager.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
#include <QTimeZone>
|
||||
#include <QVariantMap>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -25,6 +30,34 @@ struct CommitInfo {
|
||||
}
|
||||
};
|
||||
|
||||
QString timeAgoString(const QDateTime &dt)
|
||||
{
|
||||
const qint64 secs = dt.secsTo(QDateTime::currentDateTime());
|
||||
if (secs < 60) return QStringLiteral("только что");
|
||||
if (secs < 3600) return QStringLiteral("%1 мин назад").arg(secs/60);
|
||||
if (secs < 86400) return QStringLiteral("%1 ч назад").arg(secs/3600);
|
||||
if (secs < 604800) return QStringLiteral("%1 дн назад").arg(secs/86400);
|
||||
// старше недели — показываем дату
|
||||
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
|
||||
}
|
||||
|
||||
QString escHtml(const QString &s)
|
||||
{
|
||||
QString out;
|
||||
out.reserve(s.size());
|
||||
for (const QChar &c : s) {
|
||||
switch (c.unicode()) {
|
||||
case '&': out += "&"; 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;
|
||||
}
|
||||
@@ -270,3 +353,319 @@ bool GitManager::commitAll(const QString &path, const QString &message)
|
||||
setLastError(QString());
|
||||
return true;
|
||||
}
|
||||
|
||||
QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &filePath) const
|
||||
{
|
||||
QVariantList out;
|
||||
const QString vault = QDir::cleanPath(vaultPath);
|
||||
const QString rel = relativePath(vaultPath, filePath);
|
||||
if (vault.isEmpty() || rel.isEmpty())
|
||||
return out;
|
||||
git_repository *repo = nullptr;
|
||||
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
||||
return out;
|
||||
if (git_repository_head_unborn(repo)) {
|
||||
git_repository_free(repo);
|
||||
return out;
|
||||
}
|
||||
git_revwalk *walk = nullptr;
|
||||
if (git_revwalk_new(&walk, repo) < 0) {
|
||||
git_repository_free(repo);
|
||||
return out;
|
||||
}
|
||||
git_revwalk_sorting(walk, GIT_SORT_TIME);
|
||||
git_revwalk_push_head(walk);
|
||||
|
||||
git_oid oid;
|
||||
int iter = 0;
|
||||
const int maxIter = 500; // ограничение для скорости
|
||||
while (git_revwalk_next(&oid, walk) == 0 && iter < maxIter) {
|
||||
++iter;
|
||||
git_commit *commit = nullptr;
|
||||
if (git_commit_lookup(&commit, repo, &oid) < 0)
|
||||
continue;
|
||||
// Проверяем был ли файл в этом коммите и менялся ли он относительно родителя
|
||||
bool relevant = false;
|
||||
QString fileContent;
|
||||
// Получаем дерево коммита
|
||||
git_tree *tree = nullptr;
|
||||
if (git_commit_tree(&tree, commit) == 0) {
|
||||
git_tree_entry *entry = nullptr;
|
||||
if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) {
|
||||
// файл есть в этом коммите
|
||||
// Сравниваем с родителем
|
||||
if (git_commit_parentcount(commit) == 0) {
|
||||
relevant = true; // первый коммит где файл появился
|
||||
} else {
|
||||
git_commit *parent = nullptr;
|
||||
if (git_commit_parent(&parent, commit, 0) == 0) {
|
||||
git_tree *parentTree = nullptr;
|
||||
if (git_commit_tree(&parentTree, parent) == 0) {
|
||||
git_tree_entry *parentEntry = nullptr;
|
||||
if (git_tree_entry_bypath(&parentEntry, parentTree, rel.toUtf8().constData()) == 0) {
|
||||
// оба есть — сравниваем oid
|
||||
const git_oid *a = git_tree_entry_id(entry);
|
||||
const git_oid *b = git_tree_entry_id(parentEntry);
|
||||
if (!git_oid_equal(a, b))
|
||||
relevant = true;
|
||||
git_tree_entry_free(parentEntry);
|
||||
} else {
|
||||
// файл появился в этом коммите (не было в родителе)
|
||||
relevant = true;
|
||||
}
|
||||
git_tree_free(parentTree);
|
||||
}
|
||||
git_commit_free(parent);
|
||||
} else {
|
||||
relevant = true;
|
||||
}
|
||||
}
|
||||
git_tree_entry_free(entry);
|
||||
}
|
||||
git_tree_free(tree);
|
||||
}
|
||||
if (!relevant) {
|
||||
git_commit_free(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
char hashStr[41] = {0};
|
||||
git_oid_tostr(hashStr, sizeof(hashStr), &oid);
|
||||
QString hash = QString::fromUtf8(hashStr);
|
||||
QString shortHash = hash.left(7);
|
||||
const char *msg = git_commit_message(commit);
|
||||
QString message = msg ? QString::fromUtf8(msg).split('\n').first().trimmed() : QString();
|
||||
if (message.isEmpty()) message = QStringLiteral("(без сообщения)");
|
||||
if (message.length() > 80) message = message.left(80) + "...";
|
||||
|
||||
const git_signature *author = git_commit_author(commit);
|
||||
QString authorName = author && author->name ? QString::fromUtf8(author->name) : QStringLiteral("Flora");
|
||||
git_time_t t = git_commit_time(commit);
|
||||
int offset = git_commit_time_offset(commit);
|
||||
QDateTime dt = QDateTime::fromSecsSinceEpoch((qint64)t, QTimeZone(offset*60));
|
||||
// Если смещение 0 — используем локальное
|
||||
if (!dt.isValid())
|
||||
dt = QDateTime::fromSecsSinceEpoch((qint64)t);
|
||||
|
||||
QVariantMap m;
|
||||
m["hash"] = hash;
|
||||
m["shortHash"] = shortHash;
|
||||
m["message"] = message;
|
||||
m["author"] = authorName;
|
||||
m["time"] = dt;
|
||||
m["timeStr"] = dt.toString(QStringLiteral("dd.MM.yyyy hh:mm:ss"));
|
||||
m["timeAgo"] = timeAgoString(dt);
|
||||
m["hasContent"] = true;
|
||||
out.append(m);
|
||||
git_commit_free(commit);
|
||||
}
|
||||
git_revwalk_free(walk);
|
||||
git_repository_free(repo);
|
||||
return out;
|
||||
}
|
||||
|
||||
QString GitManager::fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
||||
{
|
||||
const QString vault = QDir::cleanPath(vaultPath);
|
||||
const QString rel = relativePath(vaultPath, filePath);
|
||||
if (vault.isEmpty() || rel.isEmpty() || commitHash.isEmpty())
|
||||
return QString();
|
||||
git_repository *repo = nullptr;
|
||||
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
||||
return QString();
|
||||
git_oid oid;
|
||||
if (git_oid_fromstr(&oid, commitHash.toUtf8().constData()) < 0) {
|
||||
git_repository_free(repo);
|
||||
return QString();
|
||||
}
|
||||
git_commit *commit = nullptr;
|
||||
if (git_commit_lookup(&commit, repo, &oid) < 0) {
|
||||
git_repository_free(repo);
|
||||
return QString();
|
||||
}
|
||||
git_tree *tree = nullptr;
|
||||
QString result;
|
||||
if (git_commit_tree(&tree, commit) == 0) {
|
||||
git_tree_entry *entry = nullptr;
|
||||
if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) {
|
||||
const git_oid *blobOid = git_tree_entry_id(entry);
|
||||
git_blob *blob = nullptr;
|
||||
if (git_blob_lookup(&blob, repo, blobOid) == 0) {
|
||||
const void *raw = git_blob_rawcontent(blob);
|
||||
git_off_t len = git_blob_rawsize(blob);
|
||||
result = QString::fromUtf8((const char*)raw, (int)len);
|
||||
git_blob_free(blob);
|
||||
}
|
||||
git_tree_entry_free(entry);
|
||||
}
|
||||
git_tree_free(tree);
|
||||
}
|
||||
git_commit_free(commit);
|
||||
git_repository_free(repo);
|
||||
return result;
|
||||
}
|
||||
|
||||
QString GitManager::fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
||||
{
|
||||
const QString oldContent = fileContentAt(vaultPath, filePath, commitHash);
|
||||
QFile f(filePath);
|
||||
QString newContent;
|
||||
if (f.open(QIODevice::ReadOnly))
|
||||
newContent = QString::fromUtf8(f.readAll());
|
||||
else
|
||||
newContent = QString();
|
||||
|
||||
if (oldContent == newContent)
|
||||
return QStringLiteral("Нет изменений");
|
||||
|
||||
// Используем git_patch_from_buffers для диффа
|
||||
git_patch *patch = nullptr;
|
||||
const QByteArray oldBytes = oldContent.toUtf8();
|
||||
const QByteArray newBytes = newContent.toUtf8();
|
||||
const QString rel = relativePath(vaultPath, filePath);
|
||||
const QByteArray relBytes = rel.toUtf8();
|
||||
git_patch_from_buffers(&patch,
|
||||
oldBytes.constData(), (size_t)oldBytes.size(), relBytes.constData(),
|
||||
newBytes.constData(), (size_t)newBytes.size(), relBytes.constData(),
|
||||
nullptr);
|
||||
if (!patch)
|
||||
return QStringLiteral("Не удалось построить diff");
|
||||
|
||||
QString out;
|
||||
// Заголовок
|
||||
out += QStringLiteral("--- a/%1\n").arg(rel);
|
||||
out += QStringLiteral("+++ b/%1\n").arg(rel);
|
||||
|
||||
size_t hunks = git_patch_num_hunks(patch);
|
||||
for (size_t h = 0; h < hunks; ++h) {
|
||||
const git_diff_hunk *hk = nullptr;
|
||||
size_t hunkLines = 0;
|
||||
if (git_patch_get_hunk(&hk, &hunkLines, patch, h) < 0) continue;
|
||||
out += QString::fromUtf8(hk->header, (int)hk->header_len);
|
||||
if (!out.endsWith('\n')) out += '\n';
|
||||
for (size_t l = 0; l < hunkLines; ++l) {
|
||||
const git_diff_line *line = nullptr;
|
||||
if (git_patch_get_line_in_hunk(&line, patch, h, l) < 0) continue;
|
||||
QChar origin = QChar(line->origin);
|
||||
QString text = QString::fromUtf8(line->content, (int)line->content_len);
|
||||
// line->content уже содержит \n в конце
|
||||
out += origin + text;
|
||||
if (!text.endsWith('\n') && line->content_len > 0) out += '\n';
|
||||
}
|
||||
}
|
||||
git_patch_free(patch);
|
||||
if (out.trimmed().isEmpty())
|
||||
return QStringLiteral("Нет изменений");
|
||||
return out;
|
||||
}
|
||||
|
||||
QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
||||
{
|
||||
const QString txt = fileDiffText(vaultPath, filePath, commitHash);
|
||||
if (txt == QStringLiteral("Нет изменений") || txt.startsWith(QStringLiteral("Не удалось")))
|
||||
return QStringLiteral("<p style='color:#888'>%1</p>").arg(escHtml(txt));
|
||||
|
||||
QString html;
|
||||
html.reserve(txt.size()*2);
|
||||
html += "<pre style='font-family:Menlo,monospace;font-size:12px;white-space:pre-wrap;word-wrap:break-word;margin:0'>";
|
||||
const QStringList lines = txt.split('\n');
|
||||
for (const QString &rawLine : lines) {
|
||||
if (rawLine.isEmpty()) { html += "\n"; continue; }
|
||||
QChar first = rawLine.at(0);
|
||||
QString esc = escHtml(rawLine);
|
||||
if (first == '+') {
|
||||
html += QStringLiteral("<div style='background:#e6ffec;color:#1a7f37'>%1</div>").arg(esc);
|
||||
} else if (first == '-') {
|
||||
html += QStringLiteral("<div style='background:#ffebe9;color:#cf222e'>%1</div>").arg(esc);
|
||||
} else if (first == '@') {
|
||||
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
|
||||
} else if (first == ' ') {
|
||||
html += QStringLiteral("<div style='color:#24292f'>%1</div>").arg(esc);
|
||||
} else {
|
||||
html += QStringLiteral("<div style='color:#656d76'>%1</div>").arg(esc);
|
||||
}
|
||||
}
|
||||
html += "</pre>";
|
||||
return html;
|
||||
}
|
||||
|
||||
QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash)
|
||||
{
|
||||
const QString vault = QDir::cleanPath(vaultPath);
|
||||
const QString rel = relativePath(vaultPath, filePath);
|
||||
if (vault.isEmpty() || rel.isEmpty())
|
||||
return QStringLiteral("Неверные пути");
|
||||
const QString content = fileContentAt(vaultPath, filePath, commitHash);
|
||||
// Если файл не существовал в коммите — удаляем текущий?
|
||||
// Для простоты: если контента нет, удаляем файл
|
||||
const QString absPath = QDir(vault).filePath(rel);
|
||||
if (content.isNull() && content.isEmpty()) {
|
||||
// Проверяем был ли файл в коммите вообще
|
||||
QVariantList hist = fileHistory(vaultPath, filePath);
|
||||
bool found = false;
|
||||
for (auto v : hist) if (v.toMap().value("hash").toString() == commitHash) { found = true; break; }
|
||||
if (!found) return QStringLiteral("Коммит не найден");
|
||||
// Файл не существовал — удаляем если есть
|
||||
if (QFile::exists(absPath)) {
|
||||
if (!QFile::remove(absPath))
|
||||
return QStringLiteral("Не удалось удалить файл: %1").arg(absPath);
|
||||
}
|
||||
} else {
|
||||
QDir().mkpath(QFileInfo(absPath).absolutePath());
|
||||
QSaveFile f(absPath);
|
||||
if (!f.open(QIODevice::WriteOnly))
|
||||
return QStringLiteral("Не удалось записать файл: %1").arg(absPath);
|
||||
f.write(content.toUtf8());
|
||||
if (!f.commit())
|
||||
return QStringLiteral("Не удалось сохранить файл");
|
||||
}
|
||||
// Коммитим восстановление
|
||||
const QString shortHash = commitHash.left(7);
|
||||
const QString msg = QStringLiteral("Восстановление %1 из %2").arg(QFileInfo(filePath).fileName(), shortHash);
|
||||
if (!commitAll(vault, msg))
|
||||
return lastError().isEmpty() ? QStringLiteral("Не удалось закоммитить восстановление") : lastError();
|
||||
return QString();
|
||||
}
|
||||
|
||||
QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const
|
||||
{
|
||||
const QVariantList hist = fileHistory(vaultPath, filePath);
|
||||
if (!hist.isEmpty()) {
|
||||
QDateTime dt = hist.first().toMap().value("time").toDateTime();
|
||||
if (dt.isValid()) return dt;
|
||||
}
|
||||
QFileInfo fi(filePath);
|
||||
if (fi.exists())
|
||||
return fi.lastModified();
|
||||
return QDateTime();
|
||||
}
|
||||
|
||||
QString GitManager::lastModifiedTimeString(const QString &vaultPath, const QString &filePath) const
|
||||
{
|
||||
QDateTime dt = lastModifiedTime(vaultPath, filePath);
|
||||
if (!dt.isValid()) return QStringLiteral("—");
|
||||
// Формат как в истории
|
||||
const qint64 secs = dt.secsTo(QDateTime::currentDateTime());
|
||||
if (secs < 60) return QStringLiteral("только что");
|
||||
if (secs < 3600) return QStringLiteral("%1 мин назад").arg(secs/60);
|
||||
if (secs < 86400) return QStringLiteral("%1 ч назад").arg(secs/3600);
|
||||
if (secs < 172800) return QStringLiteral("вчера %1").arg(dt.toString("hh:mm"));
|
||||
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
|
||||
}
|
||||
|
||||
bool GitManager::autoCommit(const QString &vaultPath, const QString &filePath)
|
||||
{
|
||||
if (vaultPath.isEmpty())
|
||||
return false;
|
||||
const QString fileName = QFileInfo(filePath).fileName();
|
||||
const QString msg = fileName.isEmpty()
|
||||
? QStringLiteral("Автосохранение")
|
||||
: QStringLiteral("Автосохранение: %1").arg(fileName);
|
||||
// commitAll вернёт false если нет изменений — это нормально
|
||||
bool ok = commitAll(vaultPath, msg);
|
||||
if (!ok && lastError() == QStringLiteral("Нет изменений для коммита")) {
|
||||
setLastError(QString());
|
||||
return true; // считаем успехом
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
+27
-1
@@ -2,10 +2,12 @@
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QDateTime>
|
||||
|
||||
#include <git2.h>
|
||||
|
||||
// Обёртка над 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;
|
||||
|
||||
@@ -152,3 +152,12 @@ if(TARGET KF6SyntaxHighlighting)
|
||||
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
|
||||
endif()
|
||||
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