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