Files
Flora/qml/dialogs/HistoryDialog.qml
T

455 lines
20 KiB
QML
Raw Normal View History

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 versionContent: ""
property string currentContent: ""
property string emptyMessage: qsTr("Нет версии для просмотра")
property bool busy: false
function fileDirOf(p) {
const i = p.lastIndexOf("/")
return i >= 0 ? p.substring(0, i) : ""
}
// Ссылки внутри просмотра исторической версии: только чтение, без правок
function handleHistoryLink(href) {
if (href.startsWith("flora://copycode/")) {
copyFeedback.text = qsTr("Скопировано!")
copyFeedback.visible = true
copyTimer.restart()
return
}
if (href.startsWith("flora://task/")) {
return // историческая версия — только чтение, галочки не переключаем
} else if (href.startsWith("flora://open/")) {
documentManager.openDocument(decodeURIComponent(href.substring(13)))
} else if (href.startsWith("flora://wiki/")) {
return // несуществующие заметки из истории не создаём
} else {
Qt.openUrlExternally(href)
}
}
function reload() {
if (vaultPath.length===0 || filePath.length===0) {
historyModel = []
versionContent = ""
currentContent = ""
emptyMessage = qsTr("Файл не выбран")
return
}
busy = true
// берём историю
const hist = gitManager.fileHistory(vaultPath, filePath)
historyModel = hist
listView.model = hist
if (hist.length>0) {
selectedIndex = 0
selectedHash = hist[0].hash
updateDiff()
} else {
selectedIndex = -1
selectedHash = ""
versionContent = ""
currentContent = ""
const st = gitManager.fileGitStatus(vaultPath, filePath)
if (st === "неотслеживается" || st === "новый") {
emptyMessage = qsTr("История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия.")
} else {
emptyMessage = qsTr("Нет версий для этого файла")
}
}
busy = false
}
function updateDiff() {
if (selectedHash.length===0) {
versionContent = ""
currentContent = ""
return
}
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
currentContent = gitManager.workdirContent(filePath)
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
// с подсветкой отличий от текущего файла прямо там
versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
}
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"
}
}
}
}
// Правая панель — версия как кнопка «Просмотр», с подсветкой отличий прямо там
ColumnLayout {
SplitView.fillWidth: true
SplitView.minimumWidth: 300
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.editorBg
border.color: Theme.divider
ScrollView {
id: versionScroll
anchors.fill: parent
anchors.margins: 6
clip: true
ScrollBar.vertical: ScrollBar {}
Column {
width: versionScroll.availableWidth
MarkdownView {
id: versionPreview
width: parent.width
markdown: root.versionContent
compareMarkdown: root.currentContent
vaultRoot: root.vaultPath
fileDir: root.fileDirOf(root.filePath)
emptyText: root.emptyMessage
onLinkActivated: (href) => root.handleHistoryLink(href)
}
}
}
}
// Легенда — цвета отличий от текущего файла
Row {
Layout.fillWidth: true
Layout.margins: 6
spacing: 12
Row { spacing: 4; Rectangle { width:12; height:12; color:"#7322C55E"; 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:"#73F43F5E"; border.color:"#e5484d"; 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 }
}
// Тост "Скопировано" для кнопки копирования в блоках кода
Rectangle {
id: copyToast
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 8
visible: copyFeedback.visible
color: "#333"
radius: 6
width: copyFeedback.implicitWidth + 16
height: copyFeedback.implicitHeight + 10
z: 10
Label {
id: copyFeedback
anchors.centerIn: parent
visible: false
text: qsTr("Скопировано!")
color: "white"
font.pixelSize: 11
}
}
Timer { id: copyTimer; interval: 1500; onTriggered: copyFeedback.visible = false }
}