afc15680e1
- 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, каждый шаг с проверкой содержимого и истории
448 lines
18 KiB
QML
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) } }
|
|
}
|