2026-08-17 00:05:20 +03:00
|
|
|
import QtQuick
|
|
|
|
|
import QtQuick.Controls
|
|
|
|
|
import QtQuick.Layouts
|
|
|
|
|
|
2026-08-17 01:02:05 +03:00
|
|
|
// Левая панель: файловое дерево хранилища (Фаза 3).
|
|
|
|
|
// Создание/переименование/удаление/перемещение, иконки, стрелки раскрытия,
|
|
|
|
|
// отступы подпапок, ПКМ, drag&drop. Кнопка хранилища внизу слева.
|
2026-08-17 00:05:20 +03:00
|
|
|
Item {
|
|
|
|
|
id: root
|
|
|
|
|
|
|
|
|
|
signal openVaultRequested()
|
2026-08-17 01:02:05 +03:00
|
|
|
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 {
|
2026-08-17 00:05:20 +03:00
|
|
|
anchors.fill: parent
|
2026-08-17 01:02:05 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-17 00:05:20 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 01:02:05 +03:00
|
|
|
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"
|
|
|
|
|
}
|
2026-08-17 00:05:20 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 01:02:05 +03:00
|
|
|
Label {
|
|
|
|
|
anchors.centerIn: parent
|
|
|
|
|
visible: !fileStore.ready
|
|
|
|
|
text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
|
|
|
|
|
horizontalAlignment: Text.AlignHCenter
|
|
|
|
|
color: "#bbb"
|
2026-08-17 00:05:20 +03:00
|
|
|
}
|
2026-08-17 01:02:05 +03:00
|
|
|
}
|
2026-08-17 00:05:20 +03:00
|
|
|
|
2026-08-17 01:02:05 +03:00
|
|
|
// Кнопка хранилища внизу слева
|
|
|
|
|
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"
|
2026-08-17 00:05:20 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 01:02:05 +03:00
|
|
|
// ----- Меню «+» (файл/папка) -----
|
2026-08-17 00:05:20 +03:00
|
|
|
Menu {
|
|
|
|
|
id: addMenu
|
2026-08-17 01:02:05 +03:00
|
|
|
width: 150
|
|
|
|
|
MenuItem {
|
2026-08-17 00:05:20 +03:00
|
|
|
text: qsTr("Файл")
|
2026-08-17 01:02:05 +03:00
|
|
|
onClicked: {
|
|
|
|
|
nameDialog.dialogTitle = qsTr("Новый файл")
|
|
|
|
|
nameDialog.actionLabel = qsTr("Создать")
|
|
|
|
|
nameDialog.initialText = ""
|
|
|
|
|
nameDialog.open()
|
|
|
|
|
pendingCreate.kind = "file"
|
|
|
|
|
}
|
2026-08-17 00:05:20 +03:00
|
|
|
}
|
2026-08-17 01:02:05 +03:00
|
|
|
MenuItem {
|
2026-08-17 00:05:20 +03:00
|
|
|
text: qsTr("Папка")
|
2026-08-17 01:02:05 +03:00
|
|
|
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)
|
2026-08-17 00:05:20 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|