Фазы 2-3: мульти-окна (WindowController, per-window FileStore), файловое дерево (FileStore+FileTreePanel, иконки/раскрытие/ПКМ/+drag&drop/подтверждения), NameDialog/ConfirmDialog; тест filestore (13/13)
This commit is contained in:
+345
-58
@@ -2,84 +2,371 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Левая панель: файловое дерево. Фаза 0 — каркас:
|
||||
// кнопка «+» над деревом с меню-бутербродом («Файл»/«Папка»),
|
||||
// кнопка хранилища внизу слева. Само дерево и операции — Фаза 3.
|
||||
// Левая панель: файловое дерево хранилища (Фаза 3).
|
||||
// Создание/переименование/удаление/перемещение, иконки, стрелки раскрытия,
|
||||
// отступы подпапок, ПКМ, drag&drop. Кнопка хранилища внизу слева.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
signal openVaultRequested()
|
||||
signal newFileRequested()
|
||||
signal newFolderRequested()
|
||||
signal openFileRequested(string path)
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
function baseName(p) {
|
||||
const idx = p.lastIndexOf("/")
|
||||
return idx >= 0 ? p.substring(idx + 1) : p
|
||||
}
|
||||
|
||||
// Заголовок панели: кнопка «+» + название
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 36
|
||||
color: "whitesmoke"
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 4
|
||||
spacing: 4
|
||||
// ----- Заголовок: «+» + название -----
|
||||
Rectangle {
|
||||
id: header
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 36
|
||||
color: "whitesmoke"
|
||||
|
||||
Button {
|
||||
id: addButton
|
||||
text: "+"
|
||||
implicitWidth: 28
|
||||
implicitHeight: 26
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Создать")
|
||||
onClicked: addMenu.popup()
|
||||
}
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 4
|
||||
anchors.rightMargin: 4
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: qsTr("Файлы")
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
color: "#555"
|
||||
}
|
||||
Button {
|
||||
id: addButton
|
||||
text: "+"
|
||||
implicitWidth: 28
|
||||
implicitHeight: 26
|
||||
enabled: fileStore.ready
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Создать файл или папку")
|
||||
onClicked: addMenu.popup()
|
||||
}
|
||||
}
|
||||
|
||||
// Место файлового дерева (Фаза 3)
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: "#fafafa"
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("Дерево файлов\n(Фаза 3)")
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: "#bbb"
|
||||
text: fileStore.ready ? qsTr("Файлы") : qsTr("Хранилище не выбрано")
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
color: "#555"
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка хранилища внизу слева
|
||||
Button {
|
||||
id: vaultButton
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("▨ Хранилище…")
|
||||
onClicked: root.openVaultRequested()
|
||||
}
|
||||
}
|
||||
|
||||
// Меню-«бутерброд» при нажатии «+»
|
||||
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
|
||||
x: addButton.x
|
||||
y: addButton.y + addButton.height
|
||||
width: 140
|
||||
Action {
|
||||
width: 150
|
||||
MenuItem {
|
||||
text: qsTr("Файл")
|
||||
onTriggered: root.newFileRequested()
|
||||
onClicked: {
|
||||
nameDialog.dialogTitle = qsTr("Новый файл")
|
||||
nameDialog.actionLabel = qsTr("Создать")
|
||||
nameDialog.initialText = ""
|
||||
nameDialog.open()
|
||||
pendingCreate.kind = "file"
|
||||
}
|
||||
}
|
||||
Action {
|
||||
MenuItem {
|
||||
text: qsTr("Папка")
|
||||
onTriggered: root.newFolderRequested()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user