2026-09-01 19:40:55 +03:00
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: ""
2026-09-06 18:57:45 +03:00
property string versionContent: ""
2026-09-06 19:26:53 +03:00
property string currentContent: ""
2026-09-06 20:49:14 +03:00
property string emptyMessage: qsTr ( "Нет версии для просмотра" )
2026-09-01 19:40:55 +03:00
property bool busy: false
2026-09-06 18:57:45 +03:00
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 )
}
}
2026-09-01 19:40:55 +03:00
function reload ( ) {
if ( vaultPath . length === 0 || filePath . length === 0 ) {
historyModel = [ ]
2026-09-06 19:26:53 +03:00
versionContent = ""
currentContent = ""
2026-09-06 20:49:14 +03:00
emptyMessage = qsTr ( "Файл не выбран" )
2026-09-01 19:40:55 +03:00
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 = ""
2026-09-06 18:57:45 +03:00
versionContent = ""
2026-09-06 19:26:53 +03:00
currentContent = ""
2026-09-06 20:49:14 +03:00
const st = gitManager . fileGitStatus ( vaultPath , filePath )
if ( st === "неотслеживается" || st === "новый" ) {
emptyMessage = qsTr ( "История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия." )
2026-09-01 19:40:55 +03:00
} else {
2026-09-06 20:49:14 +03:00
emptyMessage = qsTr ( "Нет версий для этого файла" )
2026-09-01 19:40:55 +03:00
}
}
busy = false
}
function updateDiff ( ) {
if ( selectedHash . length === 0 ) {
2026-09-06 18:57:45 +03:00
versionContent = ""
2026-09-06 19:26:53 +03:00
currentContent = ""
2026-09-01 19:40:55 +03:00
return
}
2026-09-06 19:26:53 +03:00
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
currentContent = gitManager . workdirContent ( filePath )
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
// с подсветкой отличий от текущего файла прямо там
2026-09-06 18:57:45 +03:00
versionContent = gitManager . fileContentAt ( vaultPath , filePath , selectedHash )
2026-09-01 19:40:55 +03:00
}
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"
}
}
}
}
2026-09-06 20:49:14 +03:00
// Правая панель — версия как кнопка «Просмотр», с подсветкой отличий прямо там
2026-09-01 19:40:55 +03:00
ColumnLayout {
SplitView.fillWidth: true
SplitView.minimumWidth: 300
spacing: 0
2026-09-06 20:49:14 +03:00
Rectangle {
2026-09-01 19:40:55 +03:00
Layout.fillWidth: true
Layout.fillHeight: true
2026-09-06 20:49:14 +03:00
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 )
2026-09-01 19:40:55 +03:00
}
}
}
}
2026-09-06 20:49:14 +03:00
// Легенда — цвета отличий от текущего файла
2026-09-01 19:40:55 +03:00
Row {
Layout.fillWidth: true
Layout.margins: 6
spacing: 12
2026-09-06 20:30:01 +03:00
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 } }
2026-09-01 19:40:55 +03:00
Label {
2026-09-06 20:49:14 +03:00
text: qsTr ( "Версия с подсветкой отличий от текущего файла" )
2026-09-01 19:40:55 +03:00
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 }
}
2026-09-06 18:57:45 +03:00
// Тост "Скопировано" для кнопки копирования в блоках кода
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 }
2026-09-01 19:40:55 +03:00
}