fdf59f5b39
- Frontmatter: шапка --- ключ: значение (источник правды свойств) - VaultSettings: .flora/settings.json (журнал, sqlEnabled, sqlMaxRows) - Database: files/tags/file_tags/links/properties, только SELECT/WITH на read-only соединении (mode=ro + query_only + зонд), лимит строк - MdParser: ~sql-блоки таблицей (parseBlocks + Renderer), шапка скрыта в просмотре - Окно: per-window Database/VaultSettings, sync при сохранении, дебаунс reindex - QML: VaultSettingsDialog, FilePropertiesDialog, меню, автообновление ~sql - FileStore.journalDir, DocumentManager.reloadDocument - Тест flora_db_test (23/23 зелёных)
335 lines
15 KiB
QML
335 lines
15 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Layouts
|
||
import Flora
|
||
|
||
Item {
|
||
id: root
|
||
|
||
function activePath() { return documentManager.pathAt(documentManager.activeIndex) }
|
||
|
||
function refreshLinksTags() {
|
||
const p = root.activePath()
|
||
linksModel.clear()
|
||
if (p.length > 0) {
|
||
const links = vaultIndex.linksOfFile(p)
|
||
for (let i = 0; i < links.length; ++i) linksModel.append({ text: links[i].text, path: links[i].path })
|
||
}
|
||
tagsModel.clear()
|
||
const tags = p.length > 0 ? vaultIndex.tagsOfFile(p) : []
|
||
for (let i = 0; i < tags.length; ++i) tagsModel.append({ tag: tags[i] })
|
||
}
|
||
|
||
Component.onCompleted: { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||
|
||
Connections {
|
||
target: fileStore
|
||
function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||
function onReadyChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||
// Внешнее изменение (синхронизация/другая программа): досинхронизировать индекс
|
||
// по mtime — дёшево, без полного рескана
|
||
function onExternalChanged() { if (fileStore.ready) vaultIndex.syncIncremental() }
|
||
}
|
||
Connections {
|
||
target: documentManager
|
||
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } }
|
||
// Сохранение обрабатывает C++ (WindowController): сначала индекс, потом база.
|
||
// Здесь не дублируем, иначе двойной парс файла.
|
||
}
|
||
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
|
||
// Правка активного файла: обновить только его (один парс), а не всё хранилище
|
||
Timer {
|
||
id: indexDebounce
|
||
interval: 800
|
||
onTriggered: {
|
||
if (fileStore.ready && documentManager.activeIndex >= 0) {
|
||
const p = documentManager.pathAt(documentManager.activeIndex)
|
||
if (p && p.length > 0) vaultIndex.updateFile(p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Делаем панели ресайзабельными внутри правой колонки — увеличенная зона по всей ширине
|
||
SplitView {
|
||
anchors.fill: parent
|
||
orientation: Qt.Vertical
|
||
handle: Rectangle {
|
||
implicitHeight: 12
|
||
color: "transparent"
|
||
Rectangle {
|
||
anchors.centerIn: parent
|
||
height: 1; width: parent.width
|
||
color: Theme.divider
|
||
}
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
color: "transparent"
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.SplitVCursor
|
||
hoverEnabled: true
|
||
onPressed: mouse.accepted = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Календарь
|
||
Rectangle {
|
||
id: calBox
|
||
SplitView.preferredHeight: 240
|
||
SplitView.minimumHeight: 180
|
||
SplitView.maximumHeight: 360
|
||
color: Theme.bgAlt
|
||
border.color: Theme.divider
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
spacing: 0
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
height: 28
|
||
color: "transparent"
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 8; anchors.rightMargin: 4
|
||
Label { text: qsTr("Календарь"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
|
||
Item { Layout.fillWidth: true }
|
||
}
|
||
}
|
||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
|
||
MonthCalendar {
|
||
id: cal
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
Layout.margins: 4
|
||
onDaySelected: (ds) => {
|
||
const path = fileStore.dailyNotePath(ds)
|
||
if (path.length > 0) documentManager.openDocument(path)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ссылки и теги
|
||
Rectangle {
|
||
id: linksBox
|
||
SplitView.preferredHeight: 260
|
||
SplitView.minimumHeight: 120
|
||
color: Theme.bg
|
||
ScrollView {
|
||
anchors.fill: parent
|
||
clip: true
|
||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||
ColumnLayout {
|
||
width: parent.width - 12
|
||
x: 6
|
||
y: 6
|
||
spacing: 8
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Label { text: qsTr("Ссылки и теги"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
|
||
Item { Layout.fillWidth: true }
|
||
Label {
|
||
text: vaultIndex.fileCount + " " + qsTr("файлов")
|
||
color: Theme.textFaint
|
||
font.pixelSize: 10
|
||
}
|
||
}
|
||
Label {
|
||
text: root.activePath().length > 0 ? qsTr("Файл: %1").arg(root.baseName(activePath())) : qsTr("Нет активного файла")
|
||
elide: Text.ElideMiddle
|
||
color: Theme.textMuted
|
||
Layout.fillWidth: true
|
||
font.pixelSize: 11
|
||
}
|
||
|
||
// Ссылки
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
color: Theme.bgAlt
|
||
radius: 8
|
||
border.color: Theme.border
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
spacing: 4
|
||
Label { text: qsTr("Ссылки"); color: Theme.textSecondary; font.pixelSize: 11; font.bold: true }
|
||
Repeater {
|
||
model: ListModel { id: linksModel }
|
||
// placeholder, используем ListView внутри
|
||
}
|
||
ListView {
|
||
id: linksList
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: Math.min(140, contentHeight)
|
||
model: linksModel
|
||
clip: true
|
||
delegate: ItemDelegate {
|
||
width: ListView.view.width
|
||
height: 28
|
||
text: model.text
|
||
font.pixelSize: 12
|
||
palette.text: Theme.text
|
||
background: Rectangle { color: hovered ? Theme.surfaceHover : "transparent"; radius: 6 }
|
||
onClicked: documentManager.openDocument(model.path)
|
||
}
|
||
ScrollBar.vertical: ScrollBar {}
|
||
}
|
||
Label {
|
||
visible: linksModel.count === 0
|
||
text: qsTr("— нет ссылок —")
|
||
color: Theme.textFaint
|
||
font.pixelSize: 11
|
||
font.italic: true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Теги
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
color: Theme.bgAlt
|
||
radius: 8
|
||
border.color: Theme.border
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
spacing: 6
|
||
Label { text: qsTr("Теги"); color: Theme.textSecondary; font.pixelSize: 11; font.bold: true }
|
||
Flow {
|
||
Layout.fillWidth: true
|
||
spacing: 6
|
||
Repeater {
|
||
model: tagsModel
|
||
delegate: TagChip {
|
||
text: "#" + model.tag
|
||
onClicked: {
|
||
tagFilesModel.clear()
|
||
const files = vaultIndex.filesWithTag(model.tag)
|
||
for (let i=0;i<files.length;++i) tagFilesModel.append({ path: files[i], name: baseName(files[i]) })
|
||
tagFilesTitle.text = qsTr("Файлы с тегом #%1 (%2)").arg(model.tag).arg(files.length)
|
||
tagFilesPopup.open()
|
||
}
|
||
}
|
||
}
|
||
Label {
|
||
visible: tagsModel.count === 0
|
||
text: qsTr("— нет тегов —")
|
||
color: Theme.textFaint
|
||
font.pixelSize: 11
|
||
font.italic: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Item { Layout.fillHeight: true }
|
||
}
|
||
}
|
||
}
|
||
|
||
// Граф
|
||
Rectangle {
|
||
id: graphBox
|
||
SplitView.fillHeight: true
|
||
SplitView.minimumHeight: 160
|
||
color: Theme.graphBg
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
spacing: 0
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
height: 32
|
||
color: "transparent"
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 8; anchors.rightMargin: 4
|
||
Label { text: qsTr("Граф связей"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
|
||
Item { Layout.fillWidth: true }
|
||
Label { text: vaultIndex.nodes.length + "·" + vaultIndex.edges.length; color: Theme.textFaint; font.pixelSize: 10 }
|
||
ToolButton {
|
||
text: "\u21BB"
|
||
font.pixelSize: 12
|
||
ToolTip.text: qsTr("Обновить")
|
||
ToolTip.visible: hovered
|
||
onClicked: vaultIndex.rebuild(fileStore.vault)
|
||
}
|
||
ToolButton {
|
||
text: "\u2630"
|
||
ToolTip.text: qsTr("Справка")
|
||
ToolTip.visible: hovered
|
||
onClicked: graphHelp.open()
|
||
}
|
||
}
|
||
}
|
||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
|
||
GraphView {
|
||
id: graph
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
nodes: vaultIndex.nodes
|
||
edges: vaultIndex.edges
|
||
onFileClicked: (path) => documentManager.openDocument(path)
|
||
}
|
||
// легенда
|
||
Row {
|
||
Layout.fillWidth: true
|
||
Layout.margins: 6
|
||
spacing: 12
|
||
Row { spacing: 4; Rectangle { width: 10; height: 10; radius: 5; color: Theme.graphNodeFile; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("Файл"); color: Theme.textMuted; font.pixelSize: 10 } }
|
||
Row { spacing: 4; Rectangle { width: 10; height: 10; radius: 5; color: Theme.graphNodeTag; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("Тег"); color: Theme.textMuted; font.pixelSize: 10 } }
|
||
Label { text: qsTr("Колесо — зум, перетаскивание — перемещение"); color: Theme.textFaint; font.pixelSize: 9; elide: Text.ElideRight; Layout.fillWidth: true; horizontalAlignment: Text.AlignRight }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function baseName(p) { const idx = p.lastIndexOf("/"); return idx >= 0 ? p.substring(idx+1) : p }
|
||
|
||
Popup {
|
||
id: tagFilesPopup
|
||
modal: true
|
||
anchors.centerIn: parent
|
||
width: 360
|
||
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
||
ColumnLayout {
|
||
width: parent.width - 16
|
||
x: 8; y: 8
|
||
Label { id: tagFilesTitle; font.bold: true; color: Theme.text }
|
||
ScrollView {
|
||
Layout.preferredHeight: Math.min(260, contentHeight)
|
||
Layout.fillWidth: true
|
||
clip: true
|
||
ListView {
|
||
model: ListModel { id: tagFilesModel }
|
||
delegate: ItemDelegate {
|
||
width: ListView.view.width
|
||
text: model.name
|
||
onClicked: { documentManager.openDocument(model.path); tagFilesPopup.close() }
|
||
}
|
||
}
|
||
}
|
||
Button { text: qsTr("Закрыть"); Layout.alignment: Qt.AlignRight; onClicked: tagFilesPopup.close() }
|
||
}
|
||
}
|
||
|
||
Popup {
|
||
id: graphHelp
|
||
anchors.centerIn: parent
|
||
width: 320
|
||
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
||
ColumnLayout {
|
||
width: parent.width - 16
|
||
x: 8; y: 8
|
||
spacing: 8
|
||
Label { text: qsTr("Граф связей"); font.bold: true; color: Theme.text }
|
||
Label { text: qsTr("• Синие узлы — файлы, зелёные — теги\n• Клик по файлу — открыть\n• Перетаскивание — переместить узел\n• Колесо — зум, перетаскивание фона — панорама\n• Двойной клик — сброс"); color: Theme.textSecondary; wrapMode: Text.Wrap; Layout.fillWidth: true; font.pixelSize: 11 }
|
||
Button { text: qsTr("Понятно"); Layout.alignment: Qt.AlignRight; onClicked: graphHelp.close() }
|
||
}
|
||
}
|
||
|
||
// скрытая модель тегов (для Flow)
|
||
ListModel { id: tagsModel }
|
||
}
|