Фазы 2-3: мульти-окна (WindowController, per-window FileStore), файловое дерево (FileStore+FileTreePanel, иконки/раскрытие/ПКМ/+drag&drop/подтверждения), NameDialog/ConfirmDialog; тест filestore (13/13)

This commit is contained in:
Flora (opencode)
2026-08-17 01:02:05 +03:00
parent ed633ae036
commit c740c9a756
12 changed files with 1305 additions and 76 deletions
+50 -16
View File
@@ -3,7 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts
// Главное окно: меню вверху, три панели (дерево / редактор / правая).
// Фазы 0-1: каркас + мастер первого запуска. Контент панелей — в следующих фазах.
// Мульти-хранилища: каждое окно привязано к своему хранилищу (windowVault).
ApplicationWindow {
id: window
width: 1280
@@ -12,16 +12,29 @@ ApplicationWindow {
title: "Flora"
color: "white"
// Мастер первого запуска: показываем, если ещё нет ни одного хранилища.
// Хранилище этого окна (задаётся WindowController); у основного окна
// может быть пустым — тогда берём основное хранилище из настроек.
property string windowVault: ""
readonly property string activeVault: {
if (fileStore.ready) return fileStore.vault
if (windowVault.length > 0) return windowVault
return vaultManager.currentVault
}
WelcomeWizard {
id: welcomeWizard
firstRun: vaultManager.vaults.length === 0
onFinished: vaultSwitcher.close()
}
Component.onCompleted: {
if (vaultManager.vaults.length === 0)
welcomeWizard.open()
onFinished: {
if (fileStore.ready) {
// Добавлено новое хранилище при уже открытом — новое окно.
windowController.openWindow(welcomeWizard.vaultPath)
} else {
// Первый запуск: применяем хранилище к этому окну.
fileStore.openVault(welcomeWizard.vaultPath)
root_status("Хранилище открыто")
}
}
}
function openWizard() {
@@ -29,6 +42,21 @@ ApplicationWindow {
welcomeWizard.open()
}
function root_status(msg, ok) {
console.log(msg)
}
Component.onCompleted: {
if (windowVault.length > 0) {
fileStore.openVault(windowVault)
} else if (vaultManager.currentVault.length > 0) {
fileStore.openVault(vaultManager.currentVault)
}
// Если хранилищ нет — мастер откроется автоматически.
if (vaultManager.vaults.length === 0)
welcomeWizard.open()
}
menuBar: MenuBar {
Menu {
title: qsTr("&Файл")
@@ -77,7 +105,7 @@ ApplicationWindow {
}
}
// Переключатель хранилищ (кнопка внизу слева, в FileTreePanel)
// Переключатель хранилищ (кнопка внизу слева)
Popup {
id: vaultSwitcher
x: 12
@@ -104,10 +132,13 @@ ApplicationWindow {
delegate: ItemDelegate {
width: ListView.view.width
text: modelData
highlighted: modelData === vaultManager.currentVault
highlighted: modelData === window.activeVault
onClicked: {
if (modelData !== vaultManager.currentVault) {
vaultManager.selectVault(modelData)
if (modelData === window.activeVault) {
vaultSwitcher.close()
} else {
windowController.openWindow(modelData)
vaultSwitcher.close()
}
}
}
@@ -120,8 +151,8 @@ ApplicationWindow {
}
Label {
text: qsTr("Текущее: %1").arg(vaultManager.currentVault.length > 0
? vaultManager.currentVault : "—")
text: qsTr("Текущее: %1").arg(window.activeVault.length > 0
? window.activeVault : "—")
color: "#666"
elide: Text.ElideMiddle
Layout.fillWidth: true
@@ -144,9 +175,12 @@ ApplicationWindow {
SplitView.preferredWidth: window.width * 0.15
SplitView.minimumWidth: 120
SplitView.maximumWidth: 500
onOpenVaultRequested: vaultSwitcher.open()
onNewFileRequested: console.log("Новый файл (Фаза 3)")
onNewFolderRequested: console.log("Новая папка (Фаза 3)")
onOpenFileRequested: (path) => {
// Фаза 4: открытие вкладки в редакторе. Пока — лог.
console.log("Открыть файл: " + path)
}
}
EditorPanel {
+76
View File
@@ -0,0 +1,76 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Подтверждение удаления. Для непустой папки требуется двойное подтверждение:
// первое нажатие показывает предупреждение, второе — удаляет.
Dialog {
id: root
property string message: ""
property bool doubleConfirm: false
signal confirmed()
title: qsTr("Подтверждение")
modal: true
width: 420
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
standardButtons: Dialog.NoButton
property bool armed: false
function resetDialog() {
root.armed = false
deleteButton.text = qsTr("Удалить")
warnLabel.visible = false
}
onOpened: root.resetDialog()
ColumnLayout {
anchors.fill: parent
spacing: 12
Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
text: root.message
}
Label {
id: warnLabel
Layout.fillWidth: true
wrapMode: Text.Wrap
visible: false
color: "#b00020"
text: qsTr("Внимание: папка содержит файлы. Они будут удалены безвозвратно.\n"
+ "Если вы уверены — нажмите «Точно удалить».")
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: qsTr("Отмена")
onClicked: root.reject()
}
Button {
id: deleteButton
text: qsTr("Удалить")
highlighted: root.armed
onClicked: {
if (root.doubleConfirm && !root.armed) {
root.armed = true
deleteButton.text = qsTr("Точно удалить")
warnLabel.visible = true
return
}
root.confirmed()
root.accept()
}
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Диалог ввода имени (новый файл/папка, переименование).
Dialog {
id: root
property string dialogTitle: qsTr("Имя")
property string actionLabel: qsTr("OK")
property string initialText: ""
property string errorText: ""
signal confirmed(string text)
title: dialogTitle
modal: true
width: 380
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
standardButtons: Dialog.NoButton
ColumnLayout {
anchors.fill: parent
spacing: 10
TextField {
id: nameField
Layout.fillWidth: true
placeholderText: qsTr("Введите имя…")
text: root.initialText
selectByMouse: true
// Русская клавиатура: первая буква — заглавная
inputMethodHints: Qt.ImhNoPredictiveText
Component.onCompleted: {
forceActiveFocus()
selectAll()
}
onAccepted: root.tryConfirm()
onTextChanged: root.errorText = ""
}
Label {
id: errorLabel
Layout.fillWidth: true
visible: root.errorText.length > 0
color: "#b00020"
wrapMode: Text.Wrap
text: root.errorText
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: qsTr("Отмена")
onClicked: root.reject()
}
Button {
text: root.actionLabel
enabled: nameField.text.trim().length > 0
onClicked: root.tryConfirm()
}
}
}
function tryConfirm() {
const n = nameField.text.trim()
if (n.length === 0) {
root.errorText = qsTr("Имя не может быть пустым.")
return
}
root.confirmed(n)
root.accept()
}
}
+345 -58
View File
@@ -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)
}
}
}