Files
Flora/qml/panels/FileTreePanel.qml
T
grendervill afc15680e1 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, каждый шаг с проверкой содержимого и истории
2026-09-01 19:40:55 +03:00

448 lines
18 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
Item {
id: root
signal openVaultRequested()
signal openFileRequested(string path)
signal historyRequested(string path)
function baseName(p) {
const idx = p.lastIndexOf("/")
return idx >= 0 ? p.substring(idx + 1) : p
}
property string filterText: ""
// ----- Заголовок -----
Rectangle {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 44
color: Theme.headerBg
border.color: Theme.divider
border.width: 0; // нижняя линия отдельно
Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.topMargin: 6
anchors.bottomMargin: 6
spacing: 6
Button {
id: addButton
text: "+"
implicitWidth: 32
implicitHeight: 28
enabled: fileStore.ready
ToolTip.visible: hovered
ToolTip.text: qsTr("Создать файл или папку")
onClicked: addMenu.popup()
}
Label {
text: fileStore.ready ? qsTr("Файлы") : qsTr("Хранилище не выбрано")
Layout.fillWidth: true
elide: Text.ElideRight
color: Theme.textSecondary
font.bold: true
font.pixelSize: 13
}
ToolButton {
id: searchToggle
checkable: true
text: "\u2315"
font.pixelSize: 14
ToolTip.text: qsTr("Поиск")
ToolTip.visible: hovered
}
ToolButton {
text: "\u2630"
font.pixelSize: 12
ToolTip.text: qsTr("Развернуть / свернуть")
ToolTip.visible: hovered
onClicked: expandMenu.popup()
}
}
}
// ----- Поле поиска -----
Rectangle {
id: searchBar
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
height: searchToggle.checked ? 38 : 0
visible: height > 0
clip: true
color: Theme.bgAlt
border.color: Theme.divider
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
RowLayout {
anchors.fill: parent
anchors.margins: 6
spacing: 6
TextField {
id: searchField
Layout.fillWidth: true
placeholderText: qsTr("Поиск файлов…")
font.pixelSize: 12
onTextChanged: root.filterText = text.trim()
Keys.onEscapePressed: { clear(); searchToggle.checked = false }
function clear() { text = ""; root.filterText = "" }
}
ToolButton {
text: "\u2715"
visible: searchField.text.length > 0
onClicked: searchField.clear()
}
}
}
// Меню развернуть/свернуть
Menu {
id: expandMenu
MenuItem { text: qsTr("Развернуть всё"); onClicked: fileStore.expandAll() }
MenuItem { text: qsTr("Свернуть всё"); onClicked: fileStore.collapseAll() }
}
Rectangle {
id: treeContainer
anchors.top: searchBar.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: vaultButton.top
color: Theme.bg
// ПКМ по пустому месту
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton)
addMenu.popup()
}
}
ListView {
id: treeView
anchors.fill: parent
anchors.topMargin: 4
clip: true
model: fileStore
currentIndex: -1
focus: true
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
spacing: 1
DropArea {
anchors.fill: parent
onDropped: (drop) => {
if (drop.source && drop.source.dragPath && fileStore.ready) {
const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault)
if (err.length > 0) showStatus(err, false)
}
}
}
delegate: Rectangle {
id: rowRect
required property string display
required property string path
required property int depth
required property bool isDir
required property bool expanded
required property bool hasChildren
required property string icon
required property int index
// Фильтрация: показываем только совпадающие; папки с детьми не показываем автоматически чтобы не захламлять
readonly property bool matchesFilter: root.filterText.length === 0 || display.toLowerCase().indexOf(root.filterText.toLowerCase()) !== -1
visible: matchesFilter
height: matchesFilter ? 32 : 0
width: treeView.width
color: {
if (dragging) return Theme.accentMuted
if (treeView.currentIndex === index) return Theme.surfaceSelected
if (hover.hovered) return Theme.surfaceHover
return "transparent"
}
border.color: treeView.currentIndex === index ? Theme.accent : "transparent"
border.width: treeView.currentIndex === index ? 1 : 0
radius: 4
property string dragPath: ""
property bool dragging: false
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6 + depth * 14
anchors.rightMargin: 8
spacing: 6
// Стрелка
Item {
Layout.preferredWidth: isDir ? 16 : 0
Layout.preferredHeight: 16
visible: isDir
Text {
anchors.centerIn: parent
text: expanded ? "\u25BE" : "\u25B8"
font.pixelSize: 12
color: Theme.textMuted
opacity: hasChildren ? 1 : 0.25
}
MouseArea {
anchors.fill: parent
enabled: hasChildren
cursorShape: Qt.PointingHandCursor
onClicked: fileStore.toggleExpand(index)
}
}
Text {
text: rowRect.iconSymbol(icon)
font.pixelSize: 14
}
Text {
text: display
Layout.fillWidth: true
elide: Text.ElideRight
color: isDir ? Theme.text : Theme.textSecondary
font.bold: isDir
font.pixelSize: 12
}
// быстрый бейдж для папок с детьми
Rectangle {
visible: isDir && hasChildren
implicitWidth: countLabel.implicitWidth + 8
implicitHeight: 16
radius: 8
color: Theme.tagChipBg
Label {
id: countLabel
anchors.centerIn: parent
text: "" // можно показать число детей, но пока стрелка
font.pixelSize: 9
color: Theme.textMuted
visible: false
}
}
}
HoverHandler { id: hover }
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
onPressed: treeView.currentIndex = index
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
rowMenu.openFor(path, isDir)
}
}
onDoubleClicked: (mouse) => {
if (mouse.button !== Qt.LeftButton) return
if (isDir) fileStore.toggleExpand(index)
else root.openFileRequested(path)
}
onPressAndHold: (mouse) => {
if (mouse.button !== Qt.LeftButton || rowRect.dragPath.length > 0) return
rowRect.dragging = true
rowRect.dragPath = path
}
}
Drag.active: rowRect.dragging
Drag.source: rowRect
Drag.hotSpot: Qt.point(width / 2, height / 2)
Drag.keys: ["application/x-flora"]
Drag.onDragFinished: rowRect.finishDrag()
Drag.onActiveChanged: if (!Drag.active) rowRect.finishDrag()
function finishDrag() { dragging = false; dragPath = "" }
DropArea {
anchors.fill: parent
onEntered: (drag) => { drag.accepted = rowRect.isDir }
onDropped: (drop) => {
if (!rowRect.isDir || !drop.source || !drop.source.dragPath) return
const err = fileStore.moveItem(drop.source.dragPath, rowRect.path)
if (err.length > 0) root.showStatus(err, false)
}
}
function iconSymbol(kind) {
switch (kind) {
case "folder": return "\uD83D\uDCC2"
case "folder-empty": return "\uD83D\uDCC1"
case "file-md": return "\uD83D\uDCDD"
case "file-html": return "\uD83C\uDF10"
case "file-pdf": return "\uD83D\uDCD5"
case "file-img": return "\uD83D\uDDBC"
case "file-code": return "\uD83D\uDCD1"
case "file-text": return "\uD83D\uDCD6"
default: return "\uD83D\uDCC4"
}
}
}
}
Column {
anchors.centerIn: parent
visible: !fileStore.ready
spacing: 8
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: "\uD83D\uDCC1"
font.pixelSize: 32
opacity: 0.5
}
Label {
text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
horizontalAlignment: Text.AlignHCenter
color: Theme.textMuted
lineHeight: 1.2
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Выбрать хранилище…")
onClicked: root.openVaultRequested()
}
}
Label {
anchors.centerIn: parent
visible: fileStore.ready && treeView.count === 0
text: root.filterText.length > 0 ? qsTr("Ничего не найдено") : qsTr("Папка пуста")
color: Theme.textMuted
}
}
Button {
id: vaultButton
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 6
height: 36
text: qsTr("▨ Хранилище…")
onClicked: root.openVaultRequested()
}
property string statusText: ""
Timer { id: statusTimer; interval: 4000; onTriggered: root.statusText = "" }
function showStatus(text, ok) { root.statusText = (ok ? "✓ " : "✕ ") + text; statusTimer.restart() }
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: vaultButton.top
anchors.bottomMargin: 6
height: 28
visible: statusText.length > 0
color: statusText.startsWith("✓") ? Theme.successBg : Theme.errorBg
border.color: statusText.startsWith("✓") ? Theme.successText : Theme.errorText
radius: 6
Label {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
verticalAlignment: Text.AlignVCenter
text: root.statusText
elide: Text.ElideRight
font.pixelSize: 11
color: statusText.startsWith("✓") ? Theme.successText : Theme.errorText
}
}
Menu {
id: addMenu
width: 180
MenuItem {
text: qsTr("Файл")
onClicked: { nameDialog.dialogTitle = qsTr("Новый файл"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "file" }
}
MenuItem {
text: qsTr("Папка")
onClicked: { nameDialog.dialogTitle = qsTr("Новая папка"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "folder" }
}
}
Menu {
id: rowMenu
property string targetPath: ""
property bool targetIsDir: false
function openFor(path, isDir) { targetPath = path; targetIsDir = isDir; popup() }
MenuItem {
text: qsTr("Открыть")
onClicked: {
if (rowMenu.targetIsDir) {
const i = rowIndexOf(rowMenu.targetPath)
if (i >= 0) fileStore.toggleExpand(i)
} else {
root.openFileRequested(rowMenu.targetPath)
}
}
}
MenuItem { text: qsTr("Создать файл внутри"); visible: rowMenu.targetIsDir; onClicked: { nameDialog.dialogTitle = qsTr("Новый файл"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "file"; pendingCreate.parent = rowMenu.targetPath } }
MenuItem { text: qsTr("Создать папку внутри"); visible: rowMenu.targetIsDir; onClicked: { nameDialog.dialogTitle = qsTr("Новая папка"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "folder"; pendingCreate.parent = rowMenu.targetPath } }
MenuSeparator { }
MenuItem {
text: qsTr("Переименовать")
onClicked: { renameTarget.path = rowMenu.targetPath; nameDialog.dialogTitle = qsTr("Переименовать"); nameDialog.actionLabel = qsTr("Переименовать"); nameDialog.initialText = root.baseName(rowMenu.targetPath); nameDialog.open(); pendingCreate.kind = "rename" }
}
MenuItem {
text: qsTr("Удалить")
onClicked: {
const p = rowMenu.targetPath; const isDir = rowMenu.targetIsDir
deleteTarget.path = p
deleteDialog.doubleConfirm = isDir && fileStore.itemHasChildren(rowIndexOf(p))
if (isDir) deleteDialog.message = fileStore.itemHasChildren(rowIndexOf(p)) ? qsTr("Удалить папку «%1» со всем её содержимым?").arg(root.baseName(p)) : qsTr("Удалить пустую папку «%1»?").arg(root.baseName(p))
else deleteDialog.message = qsTr("Удалить файл «%1»?").arg(root.baseName(p))
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 parentForCreate() { if (treeView.currentIndex>=0 && fileStore.itemIsDir(treeView.currentIndex)) return fileStore.itemPath(treeView.currentIndex); return fileStore.vault }
property var renameTarget: ({ path: "" })
property var deleteTarget: ({ path: "" })
property var pendingCreate: ({ kind: "file", parent: "" })
NameDialog {
id: nameDialog
onConfirmed: (name) => {
let err = ""
const createIn = pendingCreate.parent.length>0 ? pendingCreate.parent : parentForCreate()
if (pendingCreate.kind==="file") err=fileStore.createFile(createIn,name)
else if (pendingCreate.kind==="folder") err=fileStore.createFolder(createIn,name)
else if (pendingCreate.kind==="rename") err=fileStore.renameItem(renameTarget.path,name)
if (err.length>0) showStatus(err,false); else if (pendingCreate.kind!=="rename") showStatus(qsTr("Создано: %1").arg(name), true)
pendingCreate.parent=""
}
}
ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else showStatus(qsTr("Удалено"), true) } }
}