2026-08-17 00:05:20 +03:00
import QtQuick
import QtQuick . Controls
import QtQuick . Layouts
2026-09-01 19:25:07 +03:00
import Flora
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
}
2026-09-01 19:25:07 +03:00
property string filterText: ""
// ----- Заголовок -----
2026-08-17 01:02:05 +03:00
Rectangle {
id: header
anchors.top: parent . top
anchors.left: parent . left
anchors.right: parent . right
2026-09-01 19:25:07 +03:00
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 }
2026-08-17 01:02:05 +03:00
RowLayout {
anchors.fill: parent
2026-09-01 19:25:07 +03:00
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.topMargin: 6
anchors.bottomMargin: 6
spacing: 6
2026-08-17 01:02:05 +03:00
Button {
id: addButton
text: "+"
2026-09-01 19:25:07 +03:00
implicitWidth: 32
implicitHeight: 28
2026-08-17 01:02:05 +03:00
enabled: fileStore . ready
ToolTip.visible: hovered
ToolTip.text: qsTr ( "Создать файл или папку" )
onClicked: addMenu . popup ( )
}
Label {
text: fileStore . ready ? qsTr ( "Файлы" ) : qsTr ( "Хранилище не выбрано" )
Layout.fillWidth: true
elide: Text . ElideRight
2026-09-01 19:25:07 +03:00
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 ( )
2026-08-17 01:02:05 +03:00
}
}
}
2026-09-01 19:25:07 +03:00
// ----- Поле поиска -----
2026-08-17 01:02:05 +03:00
Rectangle {
2026-09-01 19:25:07 +03:00
id: searchBar
2026-08-17 01:02:05 +03:00
anchors.top: header . bottom
anchors.left: parent . left
anchors.right: parent . right
2026-09-01 19:25:07 +03:00
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
2026-08-17 01:02:05 +03:00
anchors.bottom: vaultButton . top
2026-09-01 19:25:07 +03:00
color: Theme . bg
2026-08-17 01:02:05 +03:00
2026-09-01 19:25:07 +03:00
// ПКМ по пустому месту
2026-08-17 01:02:05 +03:00
MouseArea {
anchors.fill: parent
acceptedButtons: Qt . RightButton
onClicked: ( mouse ) = > {
if ( mouse . button === Qt . RightButton )
2026-09-01 19:25:07 +03:00
addMenu . popup ( )
2026-08-17 01:02:05 +03:00
}
}
ListView {
id: treeView
anchors.fill: parent
2026-09-01 19:25:07 +03:00
anchors.topMargin: 4
2026-08-17 01:02:05 +03:00
clip: true
model: fileStore
currentIndex: - 1
focus: true
2026-09-01 19:25:07 +03:00
ScrollBar.vertical: ScrollBar { policy: ScrollBar . AsNeeded }
spacing: 1
2026-08-17 01:02:05 +03:00
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
2026-08-17 15:16:19 +03:00
required property int index
2026-08-17 01:02:05 +03:00
2026-09-01 19:25:07 +03:00
// Фильтрация: показываем только совпадающие; папки с детьми не показываем автоматически чтобы не захламлять
readonly property bool matchesFilter: root . filterText . length === 0 || display . toLowerCase ( ) . indexOf ( root . filterText . toLowerCase ( ) ) !== - 1
visible: matchesFilter
height: matchesFilter ? 32 : 0
2026-08-17 01:02:05 +03:00
width: treeView . width
2026-09-01 19:25:07 +03:00
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
2026-08-17 01:02:05 +03:00
property string dragPath: ""
property bool dragging: false
RowLayout {
anchors.fill: parent
2026-09-01 19:25:07 +03:00
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
}
2026-08-17 01:02:05 +03:00
MouseArea {
anchors.fill: parent
2026-09-01 19:25:07 +03:00
enabled: hasChildren
cursorShape: Qt . PointingHandCursor
2026-08-17 01:02:05 +03:00
onClicked: fileStore . toggleExpand ( index )
}
}
Text {
text: rowRect . iconSymbol ( icon )
font.pixelSize: 14
}
Text {
text: display
Layout.fillWidth: true
elide: Text . ElideRight
2026-09-01 19:25:07 +03:00
color: isDir ? Theme.text : Theme . textSecondary
2026-08-17 01:02:05 +03:00
font.bold: isDir
2026-09-01 19:25:07 +03:00
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
}
2026-08-17 01:02:05 +03:00
}
2026-08-17 00:05:20 +03:00
}
2026-09-01 19:25:07 +03:00
HoverHandler { id: hover }
2026-08-17 01:02:05 +03:00
MouseArea {
anchors.fill: parent
acceptedButtons: Qt . LeftButton | Qt . RightButton
hoverEnabled: true
2026-09-01 19:25:07 +03:00
onPressed: treeView . currentIndex = index
2026-08-17 01:02:05 +03:00
onClicked: ( mouse ) = > {
if ( mouse . button === Qt . RightButton ) {
rowMenu . openFor ( path , isDir )
}
}
onDoubleClicked: ( mouse ) = > {
if ( mouse . button !== Qt . LeftButton ) return
2026-09-01 19:25:07 +03:00
if ( isDir ) fileStore . toggleExpand ( index )
else root . openFileRequested ( path )
2026-08-17 01:02:05 +03:00
}
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 )
2026-09-01 19:25:07 +03:00
Drag.keys: [ "application/x-flora" ]
2026-08-17 01:02:05 +03:00
Drag.onDragFinished: rowRect . finishDrag ( )
Drag.onActiveChanged: if ( ! Drag . active ) rowRect . finishDrag ( )
2026-09-01 19:25:07 +03:00
function finishDrag ( ) { dragging = false ; dragPath = "" }
2026-08-17 01:02:05 +03:00
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-09-01 19:25:07 +03:00
Column {
2026-08-17 01:02:05 +03:00
anchors.centerIn: parent
visible: ! fileStore . ready
2026-09-01 19:25:07 +03:00
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
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
2026-09-01 19:25:07 +03:00
anchors.margins: 6
height: 36
2026-08-17 01:02:05 +03:00
text: qsTr ( "▨ Хранилище…" )
onClicked: root . openVaultRequested ( )
}
property string statusText: ""
Timer { id: statusTimer ; interval: 4000 ; onTriggered: root . statusText = "" }
2026-09-01 19:25:07 +03:00
function showStatus ( text , ok ) { root . statusText = ( ok ? "✓ " : "✕ " ) + text ; statusTimer . restart ( ) }
2026-08-17 01:02:05 +03:00
Rectangle {
anchors.left: parent . left
anchors.right: parent . right
2026-09-01 19:25:07 +03:00
anchors.bottom: vaultButton . top
anchors.bottomMargin: 6
height: 28
2026-08-17 01:02:05 +03:00
visible: statusText . length > 0
2026-09-01 19:25:07 +03:00
color: statusText . startsWith ( "✓" ) ? Theme.successBg : Theme . errorBg
border.color: statusText . startsWith ( "✓" ) ? Theme.successText : Theme . errorText
radius: 6
2026-08-17 01:02:05 +03:00
Label {
anchors.fill: parent
2026-09-01 19:25:07 +03:00
anchors.leftMargin: 8
anchors.rightMargin: 8
verticalAlignment: Text . AlignVCenter
2026-08-17 01:02:05 +03:00
text: root . statusText
elide: Text . ElideRight
font.pixelSize: 11
2026-09-01 19:25:07 +03:00
color: statusText . startsWith ( "✓" ) ? Theme.successText : Theme . errorText
2026-08-17 00:05:20 +03:00
}
}
Menu {
id: addMenu
2026-09-01 19:25:07 +03:00
width: 180
2026-08-17 01:02:05 +03:00
MenuItem {
2026-08-17 00:05:20 +03:00
text: qsTr ( "Файл" )
2026-09-01 19:25:07 +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-09-01 19:25:07 +03:00
onClicked: { nameDialog . dialogTitle = qsTr ( "Новая папка" ) ; nameDialog . actionLabel = qsTr ( "Создать" ) ; nameDialog . initialText = "" ; nameDialog . open ( ) ; pendingCreate . kind = "folder" }
2026-08-17 01:02:05 +03:00
}
}
Menu {
id: rowMenu
property string targetPath: ""
property bool targetIsDir: false
2026-09-01 19:25:07 +03:00
function openFor ( path , isDir ) { targetPath = path ; targetIsDir = isDir ; popup ( ) }
2026-08-17 01:02:05 +03:00
MenuItem {
text: qsTr ( "Открыть" )
onClicked: {
if ( rowMenu . targetIsDir ) {
const i = rowIndexOf ( rowMenu . targetPath )
if ( i >= 0 ) fileStore . toggleExpand ( i )
} else {
root . openFileRequested ( rowMenu . targetPath )
}
}
}
2026-09-01 19:25:07 +03:00
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 { }
2026-08-17 01:02:05 +03:00
MenuItem {
text: qsTr ( "Переименовать" )
2026-09-01 19:25:07 +03:00
onClicked: { renameTarget . path = rowMenu . targetPath ; nameDialog . dialogTitle = qsTr ( "Переименовать" ) ; nameDialog . actionLabel = qsTr ( "Переименовать" ) ; nameDialog . initialText = root . baseName ( rowMenu . targetPath ) ; nameDialog . open ( ) ; pendingCreate . kind = "rename" }
2026-08-17 01:02:05 +03:00
}
MenuItem {
text: qsTr ( "Удалить" )
onClicked: {
2026-09-01 19:25:07 +03:00
const p = rowMenu . targetPath ; const isDir = rowMenu . targetIsDir
2026-08-17 01:02:05 +03:00
deleteTarget . path = p
deleteDialog . doubleConfirm = isDir && fileStore . itemHasChildren ( rowIndexOf ( p ) )
2026-09-01 19:25:07 +03:00
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 ) )
2026-08-17 01:02:05 +03:00
deleteDialog . open ( )
}
}
}
2026-09-01 19:25:07 +03:00
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 }
2026-08-17 01:02:05 +03:00
property var renameTarget: ( { path: "" } )
property var deleteTarget: ( { path: "" } )
Массовые исправления UX: вкладки (MouseArea не блокирует ×), контент редактора (onDocIndexChanged), авто-расширение .md, ошибка дубликата, ПКМ-создание внутри папок, точки в календаре, граф (зум колесом, клик по файлу, pan правой кнопкой), fileExists(), 16/16 тестов
2026-08-17 15:42:56 +03:00
property var pendingCreate: ( { kind: "file" , parent: "" } )
2026-08-17 01:02:05 +03:00
NameDialog {
id: nameDialog
onConfirmed: ( name ) = > {
let err = ""
2026-09-01 19:25:07 +03:00
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 )
pendingCreate . parent = ""
2026-08-17 00:05:20 +03:00
}
}
2026-09-01 19:25:07 +03:00
ConfirmDialog { id: deleteDialog ; onConfirmed: { const err = fileStore . removeItem ( deleteTarget . path ) ; if ( err . length > 0 ) showStatus ( err , false ) ; else showStatus ( qsTr ( "Удалено" ) , true ) } }
}