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) else vaultIndex.syncIncremental() } } } 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) else vaultIndex.syncIncremental() } } 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=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) vaultIndex.syncIncremental() } pendingCreate.parent="" } } ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else { showStatus(qsTr("Удалено"), true); vaultIndex.syncIncremental() } } } }