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 // Пагинация: подгружаем порциями, чтобы revwalk не бежал по всей истории сразу property int historyLimit: 60 property bool hasMore: 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 // берём историю порцией limit+1: лишний элемент — признак «есть ещё» const keepHash = selectedHash const hist = gitManager.fileHistory(vaultPath, filePath, historyLimit + 1) hasMore = hist.length > historyLimit const shown = hasMore ? hist.slice(0, historyLimit) : hist historyModel = shown listView.model = shown if (shown.length>0) { // сохраняем выбор при «Показать ещё»/обновлении let idx = 0 if (keepHash.length > 0) { for (let i = 0; i < shown.length; ++i) { if (shown[i].hash === keepHash) { idx = i; break } } } selectedIndex = idx selectedHash = shown[idx].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 = "" busy = false return } // свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге currentContent = gitManager.workdirContent(filePath) // содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре», // с подсветкой отличий от текущего файла прямо там (считается в рабочем потоке) versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash) busy = currentContent.length > 0 && versionContent.length > 0 } 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" } } Button { visible: root.hasMore Layout.fillWidth: true text: qsTr("Показать ещё") onClicked: { root.historyLimit += 60; root.reload() } } } } // Правая панель — версия как кнопка «Просмотр», с подсветкой отличий прямо там 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) onDiffApplied: root.busy = false } } } } // Легенда — цвета отличий от текущего файла 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.willDelete = !gitManager.fileExistedAt(root.vaultPath, root.filePath, root.selectedHash) confirmRestore.open() } } } } // Подтверждение восстановления Dialog { id: confirmRestore property bool willDelete: false 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: confirmRestore.willDelete ? qsTr("ВНИМАНИЕ: в выбранной версии этого файла не было — файл будет УДАЛЁН (это тоже сохранится как новая версия, отменить можно через историю).") : qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.") wrapMode: Text.Wrap Layout.fillWidth: true color: confirmRestore.willDelete ? Theme.errorText : 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: confirmRestore.willDelete ? qsTr("Удалить файл") : 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