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