Files
Flora/qml/panels/FileTreePanel.qml
T

372 lines
13 KiB
QML
Raw Normal View History

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
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)
}
}
}