Files
Flora/qml/panels/FileTreePanel.qml
T

373 lines
13 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Левая панель: файловое дерево хранилища (Фаза 3).
// Создание/переименование/удаление/перемещение, иконки, стрелки раскрытия,
// отступы подпапок, ПКМ, drag&drop. Кнопка хранилища внизу слева.
Item {
id: root
signal openVaultRequested()
signal openFileRequested(string path)
function baseName(p) {
const idx = p.lastIndexOf("/")
return idx >= 0 ? p.substring(idx + 1) : p
}
// ----- Заголовок: «+» + название -----
Rectangle {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 36
color: "whitesmoke"
RowLayout {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
spacing: 4
Button {
id: addButton
text: "+"
implicitWidth: 28
implicitHeight: 26
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: "#555"
}
}
}
Rectangle {
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: vaultButton.top
color: "#fafafa"
// ПКМ по пустому месту дерева — то же меню, что и «+»
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton)
addMenu.x = mouse.x; addMenu.y = mouse.y; addMenu.popup()
}
}
ListView {
id: treeView
anchors.fill: parent
clip: true
model: fileStore
currentIndex: -1
focus: true
ScrollBar.vertical: ScrollBar {}
// Перемещение на пустое место = в корень хранилища
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
width: treeView.width
height: 30
color: rowRect.dragging ? "#d6e4ff"
: (treeView.currentIndex === index ? "#e6f0ff"
: (index % 2 === 0 ? "#fafafa" : "#f4f4f4"))
property string dragPath: ""
property bool dragging: false
RowLayout {
anchors.fill: parent
anchors.leftMargin: 4 + depth * 16
anchors.rightMargin: 6
spacing: 4
Text {
text: isDir ? (expanded ? "\u25BE" : "\u25B8") : ""
font.pixelSize: 12
Layout.preferredWidth: isDir ? 16 : 8
horizontalAlignment: Text.AlignHCenter
color: "#666"
opacity: hasChildren ? 1 : 0.35
MouseArea {
anchors.fill: parent
visible: hasChildren
onClicked: fileStore.toggleExpand(index)
}
}
Text {
text: rowRect.iconSymbol(icon)
font.pixelSize: 14
}
Text {
text: display
Layout.fillWidth: true
elide: Text.ElideRight
color: isDir ? "#1a1a1a" : "#333"
font.bold: isDir
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
onPressed: {
treeView.currentIndex = index
}
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
rowMenu.x = mouse.x; rowMenu.y = mouse.y
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"
}
}
}
}
Label {
anchors.centerIn: parent
visible: !fileStore.ready
text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
horizontalAlignment: Text.AlignHCenter
color: "#bbb"
}
}
// Кнопка хранилища внизу слева
Button {
id: vaultButton
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
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.top: vaultButton.top
height: 24
visible: statusText.length > 0
color: statusText.startsWith("✓") ? "#e5f6e5" : "#fdeaea"
Label {
anchors.fill: parent
anchors.margins: 6
text: root.statusText
elide: Text.ElideRight
font.pixelSize: 11
color: statusText.startsWith("✓") ? "#256029" : "#9c1c1c"
}
}
// ----- Меню «+» (файл/папка) -----
Menu {
id: addMenu
width: 150
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("Переименовать")
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()
}
}
}
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" })
NameDialog {
id: nameDialog
onConfirmed: (name) => {
let err = ""
if (pendingCreate.kind === "file") {
err = fileStore.createFile(parentForCreate(), name)
} else if (pendingCreate.kind === "folder") {
err = fileStore.createFolder(parentForCreate(), name)
} else if (pendingCreate.kind === "rename") {
err = fileStore.renameItem(renameTarget.path, name)
}
if (err.length > 0) showStatus(err, false)
}
}
ConfirmDialog {
id: deleteDialog
onConfirmed: {
const err = fileStore.removeItem(deleteTarget.path)
if (err.length > 0) showStatus(err, false)
}
}
}