diff --git a/.gitignore b/.gitignore
index 9dae567..8d7dc1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,5 +25,25 @@ Thumbs.db
Flora.app/
Flora.dmg
+# Тестирование (временные артефакты, не коммитим)
+Testing/
+CTestTestfile.cmake
+DartConfiguration.tcl
+coverage/
+*.gcda
+*.gcno
+*.gcov
+*.profraw
+*.profdata
+__pycache__/
+.pytest_cache/
+.tmp/
+tmp/
+.flora/
+.test-vault/
+test-vault/
+*.tmp
+
# Все markdown-файлы (служебные документы проекта не пушим в gitea)
-*.md
\ No newline at end of file
+*.md
+!tests/*.md
\ No newline at end of file
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d6d1b72..e8a660a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -129,6 +129,12 @@ qt_add_qml_module(flora
qml/dialogs/TableEditor.qml
)
+# Theme как синглтон
+set_source_files_properties(qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
+qt_target_qml_sources(flora
+ QML_FILES qml/Theme.qml
+)
+
# --- Тесты ---
if(BUILD_TESTING)
add_subdirectory(tests)
diff --git a/qml/MainWindow.qml b/qml/MainWindow.qml
index 8c51611..a199b5d 100644
--- a/qml/MainWindow.qml
+++ b/qml/MainWindow.qml
@@ -1,19 +1,24 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Главное окно: меню вверху, три панели (дерево / редактор / правая).
-// Мульти-хранилища: каждое окно привязано к своему хранилищу (windowVault).
ApplicationWindow {
id: window
width: 1280
height: 800
+ minimumWidth: 900
+ minimumHeight: 600
visible: true
title: "Flora"
- color: "white"
+ color: Theme.bg
+ palette.window: Theme.bg
+ palette.windowText: Theme.text
+ palette.base: Theme.surface
+ palette.text: Theme.text
+ palette.highlight: Theme.accent
+ palette.highlightedText: "white"
- // Хранилище этого окна (задаётся WindowController); у основного окна
- // может быть пустым — тогда берём основное хранилище из настроек.
property string windowVault: ""
readonly property string activeVault: {
@@ -27,10 +32,8 @@ ApplicationWindow {
firstRun: vaultManager.vaults.length === 0
onFinished: {
if (fileStore.ready) {
- // Добавлено новое хранилище при уже открытом — новое окно.
windowController.openWindow(welcomeWizard.vaultPath)
} else {
- // Первый запуск: применяем хранилище к этому окну.
fileStore.openVault(welcomeWizard.vaultPath)
root_status("Хранилище открыто")
}
@@ -43,7 +46,7 @@ ApplicationWindow {
}
function root_status(msg, ok) {
- console.log(msg)
+ // статус можно показать в UI, пока просто игнорируем
}
Component.onCompleted: {
@@ -52,34 +55,14 @@ ApplicationWindow {
} else if (vaultManager.currentVault.length > 0) {
fileStore.openVault(vaultManager.currentVault)
}
- // Если хранилищ нет — мастер откроется автоматически.
if (vaultManager.vaults.length === 0) {
welcomeWizard.open()
- } else {
- // Автооткрытие первого .md файла для тестирования
- autoOpenTimer.start()
- }
- }
-
- Timer {
- id: autoOpenTimer
- interval: 200
- onTriggered: {
- if (!fileStore.ready) return
- for (let i = 0; i < fileStore.rowCount(); i++) {
- if (!fileStore.itemIsDir(i)) {
- const p = fileStore.itemPath(i)
- if (p.endsWith(".md")) {
- documentManager.openDocument(p)
- console.log("AUTO-OPENED:", p, "len:", documentManager.contentAt(documentManager.activeIndex).length)
- break
- }
- }
- }
}
}
menuBar: MenuBar {
+ // Переопределяем цвета под тему (Basic стиль не красит сам)
+ background: Rectangle { color: Theme.bgAlt; border.color: Theme.divider }
Menu {
title: qsTr("&Файл")
Action { text: qsTr("Добавить хранилище…"); onTriggered: window.openWizard() }
@@ -98,13 +81,35 @@ ApplicationWindow {
MenuSeparator { }
Action {
text: qsTr("Вернуться к предыдущей версии…"); enabled: false
- // Диалог истории с diff-режимом — Фаза 9.
}
}
Menu {
title: qsTr("&Вид")
- Action { text: qsTr("Светлая тема"); checkable: true; checked: true }
- Action { text: qsTr("Тёмная тема"); checkable: true }
+ ActionGroup { id: themeGroup }
+ Action {
+ text: qsTr("Светлая тема")
+ checkable: true
+ checked: settingsStore.theme !== "dark"
+ ActionGroup.group: themeGroup
+ onTriggered: settingsStore.theme = "light"
+ }
+ Action {
+ text: qsTr("Тёмная тема")
+ checkable: true
+ checked: settingsStore.theme === "dark"
+ ActionGroup.group: themeGroup
+ onTriggered: settingsStore.theme = "dark"
+ }
+ MenuSeparator { }
+ Action {
+ text: qsTr("Сбросить ширины панелей")
+ onTriggered: {
+ settingsStore.leftPanelWidth = 220
+ settingsStore.rightPanelWidth = 220
+ leftPanel.width = 220
+ rightPanel.width = 220
+ }
+ }
}
Menu {
title: qsTr("&Справка")
@@ -121,28 +126,31 @@ ApplicationWindow {
standardButtons: Dialog.Ok
modal: true
anchors.centerIn: parent
+ background: Rectangle { color: Theme.surface; border.color: Theme.border; radius: 8 }
Label {
+ color: Theme.text
text: "Flora\nМинималистичный markdown-заметочник (GPL-3.0)\n"
+ "Версия " + Qt.application.version
}
}
- // Переключатель хранилищ (кнопка внизу слева)
Popup {
id: vaultSwitcher
x: 12
y: window.height - height - 12
- width: 320
+ width: 340
modal: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 8 }
ColumnLayout {
width: parent.width
- spacing: 6
+ spacing: 8
Label {
text: qsTr("Хранилища")
font.bold: true
+ color: Theme.text
}
ListView {
@@ -151,10 +159,12 @@ ApplicationWindow {
Layout.fillWidth: true
model: vaultManager.vaults
clip: true
+ ScrollBar.vertical: ScrollBar {}
delegate: ItemDelegate {
width: ListView.view.width
text: modelData
highlighted: modelData === window.activeVault
+ palette.highlight: Theme.accent
onClicked: {
if (modelData === window.activeVault) {
vaultSwitcher.close()
@@ -169,15 +179,16 @@ ApplicationWindow {
Label {
visible: vaultList.count === 0
text: qsTr("Хранилищ пока нет")
- color: "#888"
+ color: Theme.textMuted
}
Label {
text: qsTr("Текущее: %1").arg(window.activeVault.length > 0
? window.activeVault : "—")
- color: "#666"
+ color: Theme.textSecondary
elide: Text.ElideMiddle
Layout.fillWidth: true
+ font.pixelSize: 11
}
Button {
@@ -189,14 +200,35 @@ ApplicationWindow {
}
SplitView {
+ id: mainSplit
anchors.fill: parent
orientation: Qt.Horizontal
+ // тонкая линия-разделитель под тему
+ handle: Rectangle {
+ implicitWidth: 1
+ implicitHeight: 1
+ color: Theme.divider
+ Rectangle { // увеличенная зона захвата
+ anchors.centerIn: parent
+ width: 8; height: parent.height
+ color: "transparent"
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.SplitHCursor
+ onPressed: mouse.accepted = false
+ }
+ }
+ }
FileTreePanel {
id: leftPanel
- SplitView.preferredWidth: window.width * 0.15
- SplitView.minimumWidth: 120
+ SplitView.preferredWidth: settingsStore.leftPanelWidth
+ SplitView.minimumWidth: 180
SplitView.maximumWidth: 500
+ onWidthChanged: {
+ if (width >= 180 && width <= 500)
+ settingsStore.leftPanelWidth = width
+ }
onOpenVaultRequested: vaultSwitcher.open()
onOpenFileRequested: (path) => {
@@ -209,15 +241,21 @@ ApplicationWindow {
}
EditorPanel {
+ id: centerPanel
SplitView.fillWidth: true
SplitView.minimumWidth: 300
- height: window.height - menuBar.height
+ height: window.height - (menuBar ? menuBar.height : 0)
}
RightPanel {
- SplitView.preferredWidth: window.width * 0.15
- SplitView.minimumWidth: 140
+ id: rightPanel
+ SplitView.preferredWidth: settingsStore.rightPanelWidth
+ SplitView.minimumWidth: 200
SplitView.maximumWidth: 500
+ onWidthChanged: {
+ if (width >= 200 && width <= 500)
+ settingsStore.rightPanelWidth = width
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/qml/Theme.qml b/qml/Theme.qml
new file mode 100644
index 0000000..8fdfd88
--- /dev/null
+++ b/qml/Theme.qml
@@ -0,0 +1,76 @@
+pragma Singleton
+import QtQuick
+
+QtObject {
+ id: theme
+ // true = тёмная, false = светлая. Читаем из SettingsStore.
+ readonly property bool isDark: {
+ // settingsStore — context property из main.cpp
+ try { return settingsStore && settingsStore.theme === "dark" } catch(e) { return false }
+ }
+
+ // Быстрый хелпер для QML: theme.dark ? "#xxx" : "#yyy"
+ // Основная палитра
+ readonly property color bg: isDark ? "#0f1115" : "#ffffff"
+ readonly property color bgAlt: isDark ? "#1a1d23" : "#f8f9fb"
+ readonly property color surface: isDark ? "#232730" : "#ffffff"
+ readonly property color surfaceHover: isDark ? "#2a303d" : "#f1f3f5"
+ readonly property color surfaceSelected: isDark ? "#2d3d56" : "#e7f0ff"
+ readonly property color surfaceSelectedHover: isDark ? "#344866" : "#d6e4ff"
+ readonly property color border: isDark ? "#2a303d" : "#e6e8ec"
+ readonly property color borderStrong: isDark ? "#353c4e" : "#d0d5dc"
+ readonly property color divider: isDark ? "#262b36" : "#eceef1"
+
+ // Текст
+ readonly property color text: isDark ? "#e6e8ee" : "#1a1d23"
+ readonly property color textSecondary: isDark ? "#a8b0c0" : "#505866"
+ readonly property color textMuted: isDark ? "#727c90" : "#868e96"
+ readonly property color textFaint: isDark ? "#545d71" : "#adb5bd"
+ readonly property color textInverse: isDark ? "#1a1d23" : "#ffffff"
+
+ // Акцент
+ readonly property color accent: isDark ? "#4c8ef7" : "#3b82f6"
+ readonly property color accentHover: isDark ? "#6aa0ff" : "#2563eb"
+ readonly property color accentMuted: isDark ? "#2a3d5a" : "#dbeafe"
+ readonly property color accentText: isDark ? "#b8d0ff" : "#1e40af"
+
+ // Специальные
+ readonly property color headerBg: isDark ? "#1d2028" : "#f8f9fb"
+ readonly property color toolbarBg: isDark ? "#232730" : "#f3f4f6"
+ readonly property color tabActiveBg: isDark ? "#2a303d" : "#ffffff"
+ readonly property color tabInactiveBg: isDark ? "#1d2028" : "#eceef1"
+ readonly property color editorBg: isDark ? "#1a1d23" : "#ffffff"
+ readonly property color editorText: isDark ? "#e6e8ee" : "#1a1d23"
+ readonly property color selectionBg: isDark ? "#2d3d56" : "#cfe0ff"
+ readonly property color codeBg: isDark ? "#252a33" : "#f8f9fb"
+ readonly property color blockquoteBg: isDark ? "#222832" : "#f8f9fb"
+ readonly property color hr: isDark ? "#2a303d" : "#e9ecef"
+
+ // Статусы
+ readonly property color successBg: isDark ? "#1e3320" : "#e5f6e5"
+ readonly property color successText: isDark ? "#7bd88f" : "#256029"
+ readonly property color errorBg: isDark ? "#3d1f24" : "#fdeaea"
+ readonly property color errorText: isDark ? "#ff8a9b" : "#9c1c1c"
+ readonly property color warningBg: isDark ? "#3d2f1a" : "#fff4e0"
+ readonly property color warningText: isDark ? "#ffb86a" : "#7a4a00"
+
+ // Граф / календарь
+ readonly property color graphBg: isDark ? "#1d2028" : "#eef1f5"
+ readonly property color graphEdge: isDark ? "#353c4e" : "#b9c6d6"
+ readonly property color graphNodeFile: isDark ? "#4c8ef7" : "#3f7fd0"
+ readonly property color graphNodeTag: isDark ? "#4ade80" : "#3aa06a"
+ readonly property color graphLabel: isDark ? "#868e96" : "#5a6a7d"
+ readonly property color calendarToday: isDark ? "#2d3d56" : "#dbeafe"
+ readonly property color calendarDot: isDark ? "#4c8ef7" : "#3b82f6"
+ readonly property color tagChipBg: isDark ? "#2a303d" : "#e8eef7"
+ readonly property color tagChipBgHover: isDark ? "#2d3d56" : "#dbeafe"
+ readonly property color tagChipBorder: isDark ? "#353c4e" : "#b7c9e0"
+ readonly property color tagChipText: isDark ? "#a8c0ff" : "#2b4b74"
+
+ // Тени
+ readonly property color shadow: isDark ? "#0a0c0f" : "#000000"
+
+ function alpha(c, a) {
+ return Qt.rgba(c.r, c.g, c.b, a)
+ }
+}
diff --git a/qml/dialogs/ConfirmDialog.qml b/qml/dialogs/ConfirmDialog.qml
index 944208f..68090d2 100644
--- a/qml/dialogs/ConfirmDialog.qml
+++ b/qml/dialogs/ConfirmDialog.qml
@@ -1,9 +1,8 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Подтверждение удаления. Для непустой папки требуется двойное подтверждение:
-// первое нажатие показывает предупреждение, второе — удаляет.
Dialog {
id: root
@@ -14,10 +13,11 @@ Dialog {
title: qsTr("Подтверждение")
modal: true
- width: 420
+ width: 440
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
standardButtons: Dialog.NoButton
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
property bool armed: false
@@ -25,6 +25,7 @@ Dialog {
root.armed = false
deleteButton.text = qsTr("Удалить")
warnLabel.visible = false
+ warnBox.visible = false
}
onOpened: root.resetDialog()
@@ -33,38 +34,54 @@ Dialog {
anchors.fill: parent
spacing: 12
- Label {
+ Row {
+ spacing: 10
Layout.fillWidth: true
- wrapMode: Text.Wrap
- text: root.message
+ Label { text: "\u26A0"; font.pixelSize: 20; color: Theme.warningText; visible: root.doubleConfirm }
+ Label {
+ width: parent.width - 30
+ wrapMode: Text.Wrap
+ text: root.message
+ color: Theme.text
+ font.pixelSize: 13
+ }
}
- Label {
- id: warnLabel
+ Rectangle {
+ id: warnBox
Layout.fillWidth: true
- wrapMode: Text.Wrap
visible: false
- color: "#b00020"
- text: qsTr("Внимание: папка содержит файлы. Они будут удалены безвозвратно.\n"
- + "Если вы уверены — нажмите «Точно удалить».")
+ color: Theme.warningBg
+ border.color: Theme.warningText
+ radius: 6
+ height: warnLabel.implicitHeight + 16
+ Label {
+ id: warnLabel
+ anchors.fill: parent
+ anchors.margins: 8
+ wrapMode: Text.Wrap
+ visible: false
+ color: Theme.warningText
+ text: qsTr("Внимание: папка содержит файлы. Они будут удалены безвозвратно.\nЕсли вы уверены — нажмите «Точно удалить».")
+ font.pixelSize: 11
+ }
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
- Button {
- text: qsTr("Отмена")
- onClicked: root.reject()
- }
+ Button { text: qsTr("Отмена"); onClicked: root.reject() }
Button {
id: deleteButton
text: qsTr("Удалить")
highlighted: root.armed
+ palette.button: root.armed ? Theme.errorText : undefined
onClicked: {
if (root.doubleConfirm && !root.armed) {
root.armed = true
deleteButton.text = qsTr("Точно удалить")
warnLabel.visible = true
+ warnBox.visible = true
return
}
root.confirmed()
@@ -73,4 +90,4 @@ Dialog {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/qml/dialogs/NameDialog.qml b/qml/dialogs/NameDialog.qml
index b8b2018..56ca0fb 100644
--- a/qml/dialogs/NameDialog.qml
+++ b/qml/dialogs/NameDialog.qml
@@ -1,8 +1,8 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Диалог ввода имени (новый файл/папка, переименование).
Dialog {
id: root
@@ -15,27 +15,29 @@ Dialog {
title: dialogTitle
modal: true
- width: 380
+ width: 400
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
standardButtons: Dialog.NoButton
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
ColumnLayout {
anchors.fill: parent
- spacing: 10
+ spacing: 12
+
+ Label { text: dialogTitle; font.bold: true; color: Theme.text; font.pixelSize: 14 }
TextField {
id: nameField
Layout.fillWidth: true
placeholderText: qsTr("Введите имя…")
+ placeholderTextColor: Theme.textFaint
+ color: Theme.text
text: root.initialText
selectByMouse: true
- // Русская клавиатура: первая буква — заглавная
inputMethodHints: Qt.ImhNoPredictiveText
- Component.onCompleted: {
- forceActiveFocus()
- selectAll()
- }
+ background: Rectangle { color: Theme.bgAlt; border.color: nameField.activeFocus ? Theme.accent : Theme.border; radius: 6 }
+ Component.onCompleted: { forceActiveFocus(); selectAll() }
onAccepted: root.tryConfirm()
onTextChanged: root.errorText = ""
}
@@ -44,21 +46,22 @@ Dialog {
id: errorLabel
Layout.fillWidth: true
visible: root.errorText.length > 0
- color: "#b00020"
+ color: Theme.errorText
wrapMode: Text.Wrap
text: root.errorText
+ font.pixelSize: 11
+ padding: 6
+ background: Rectangle { visible: root.errorText.length>0; color: Theme.errorBg; radius: 6 }
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
- Button {
- text: qsTr("Отмена")
- onClicked: root.reject()
- }
+ Button { text: qsTr("Отмена"); onClicked: root.reject() }
Button {
text: root.actionLabel
enabled: nameField.text.trim().length > 0
+ highlighted: true
onClicked: root.tryConfirm()
}
}
@@ -66,11 +69,8 @@ Dialog {
function tryConfirm() {
const n = nameField.text.trim()
- if (n.length === 0) {
- root.errorText = qsTr("Имя не может быть пустым.")
- return
- }
+ if (n.length === 0) { root.errorText = qsTr("Имя не может быть пустым."); return }
root.confirmed(n)
root.accept()
}
-}
\ No newline at end of file
+}
diff --git a/qml/dialogs/ProviderSetupGuide.qml b/qml/dialogs/ProviderSetupGuide.qml
index 757d01a..72f8a39 100644
--- a/qml/dialogs/ProviderSetupGuide.qml
+++ b/qml/dialogs/ProviderSetupGuide.qml
@@ -1,9 +1,8 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Диалог настройки провайдера синхронизации с пошаговой инструкцией,
-// проверкой соединения и каталогом ошибок (ErrorCatalog в SyncManager).
Dialog {
id: root
@@ -22,6 +21,7 @@ Dialog {
height: 520
anchors.centerIn: parent
closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
function defaultUrl() {
switch (root.providerIndex) {
@@ -48,13 +48,14 @@ Dialog {
ColumnLayout {
anchors.fill: parent
spacing: 10
+ anchors.margins: 4
- // Инструкция
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 150
- color: "#f4f6fa"
- radius: 6
+ color: Theme.bgAlt
+ border.color: Theme.border
+ radius: 8
Flickable {
anchors.fill: parent
anchors.margins: 8
@@ -64,85 +65,80 @@ Dialog {
contentHeight: instText.implicitHeight
Text {
id: instText
- width: parent.width
+ width: parent.width - 16
wrapMode: Text.Wrap
textFormat: Text.MarkdownText
- color: "#333"
+ color: Theme.textSecondary
+ linkColor: Theme.accent
+ onLinkActivated: (url) => Qt.openUrlExternally(url)
text: root.providerIndex === 0
- ? "**Как подключить GitHub:**\n\n"
- + "1. Создайте токен: [github.com/settings/tokens](https://github.com/settings/tokens) — scope **repo** (классический) или Fine-grained с правом **Contents: Read and write**.\n"
- + "2. Создайте **пустой** репозиторий (без README/.gitignore — иначе конфликт истории): New repository.\n"
- + "3. Вставьте токен ниже и нажмите «Проверить».\n"
- + "4. Альтернатива — SSH-ключ (Подключение → SSH, доступно в Фазе 11)."
+ ? "**Как подключить GitHub:**\n\n" + "1. Создайте токен: [github.com/settings/tokens](https://github.com/settings/tokens) — scope **repo**.\n" + "2. Создайте **пустой** репозиторий (без README).\n" + "3. Вставьте токен ниже и нажмите «Проверить».\n"
: root.providerIndex === 1
- ? "**Как подключить GitLab:**\n\n"
- + "1. Токен: Настройки → Access Tokens — scope **api** (или **read_repository** + **write_repository**).\n"
- + "2. Создайте **пустой** репозиторий в своём аккаунте/группе.\n"
- + "3. Для self-hosted: укажите адрес инстанса, например http://gitlab.local. Вставьте токен и нажмите «Проверить».\n"
- + "4. Альтернатива — SSH-ключ (доступно в Фазе 11)."
+ ? "**Как подключить GitLab:**\n\n" + "1. Токен: Настройки → Access Tokens — scope **api**.\n" + "2. Создайте **пустой** репозиторий.\n" + "3. Для self-hosted: укажите адрес инстанса.\n"
: root.providerIndex === 2
- ? "**Как подключить Gitea:**\n\n"
- + "1. Токен: Настройки аккаунта → Приложения → **Создать новый токен** (scope **repo**).\n"
- + "2. Создайте **пустой** репозиторий на своём инстансе.\n"
- + "3. Укажите адрес инстанса (например http://localhost:3000), вставьте токен и нажмите «Проверить».\n"
- + "4. Альтернатива — SSH-ключ (доступно в Фазе 11)."
+ ? "**Как подключить Gitea:**\n\n" + "1. Токен: Настройки → Приложения → **Создать новый токен** (scope **repo**).\n" + "2. Создайте **пустой** репозиторий.\n"
: root.providerIndex === 3
- ? "**Как подключить Nextcloud (WebDAV):**\n\n"
- + "1. Откройте приложение Files, создайте папку для синхронизации (например **Flora**).\n"
- + "2. Адрес WebDAV: https://ВАШ_ХОСТ/remote.php/dav/files/ВАШ_ЛОГИН/\n"
- + "3. Укажите логин и пароль аккаунта Nextcloud и нажмите «Проверить».\n"
- + "4. Рекомендуется создать пароль приложения в Настройках → Безопасность."
- : "**Как подключить Syncthing:**\n\n"
- + "1. Установите и запустите Syncthing на этом устройстве.\n"
- + "2. API-ключ: Действия → Настройки → Показывать расширенное → API-ключ.\n"
- + "3. Адрес API по умолчанию: http://127.0.0.1:8384. Вставьте ключ и нажмите «Проверить».\n"
- + "4. Затем добавьте папку хранилища в Syncthing как обычную папку."
+ ? "**Как подключить Nextcloud (WebDAV):**\n\n" + "1. Создайте папку для синхронизации (например **Flora**).\n" + "2. Адрес WebDAV: https://ВАШ_ХОСТ/remote.php/dav/files/ВАШ_ЛОГИН/\n" + "3. Укажите логин и пароль.\n"
+ : "**Как подключить Syncthing:**\n\n" + "1. Установите и запустите Syncthing.\n" + "2. API-ключ: Действия → Настройки → API-ключ.\n" + "3. Адрес по умолчанию: http://127.0.0.1:8384.\n"
}
}
}
- // Поля
ColumnLayout {
Layout.fillWidth: true
spacing: 6
- Label { text: qsTr("Адрес сервера") }
- TextField { id: urlField; Layout.fillWidth: true }
- Label {
- visible: root.providerIndex === 3
- text: qsTr("Логин (для WebDAV):")
+ Label { text: qsTr("Адрес сервера"); color: Theme.textSecondary; font.bold: true; font.pixelSize: 11 }
+ TextField {
+ id: urlField; Layout.fillWidth: true
+ placeholderTextColor: Theme.textFaint
+ color: Theme.text
+ background: Rectangle { color: Theme.bgAlt; border.color: urlField.activeFocus ? Theme.accent : Theme.border; radius: 6 }
}
+ Label { visible: root.providerIndex === 3; text: qsTr("Логин (для WebDAV):"); color: Theme.textSecondary; font.pixelSize: 11 }
TextField {
id: userField
Layout.fillWidth: true
visible: root.providerIndex === 3
placeholderText: qsTr("Логин Nextcloud")
+ placeholderTextColor: Theme.textFaint
+ color: Theme.text
+ background: Rectangle { color: Theme.bgAlt; border.color: userField.activeFocus ? Theme.accent : Theme.border; radius: 6 }
}
- Label { text: tokenName() }
+ Label { text: tokenName(); color: Theme.textSecondary; font.bold: true; font.pixelSize: 11 }
TextField {
id: tokenField
Layout.fillWidth: true
echoMode: TextInput.Password
placeholderText: qsTr("Вставьте значение")
+ placeholderTextColor: Theme.textFaint
+ color: Theme.text
+ background: Rectangle { color: Theme.bgAlt; border.color: tokenField.activeFocus ? Theme.accent : Theme.border; radius: 6 }
}
}
- // Результат / ошибка
- Label {
- id: resultLabel
+ Rectangle {
Layout.fillWidth: true
- wrapMode: Text.Wrap
- visible: text.length > 0
- color: root.connected ? "green" : "#b00020"
+ visible: resultLabel.text.length > 0
+ color: root.connected ? Theme.successBg : Theme.errorBg
+ border.color: root.connected ? Theme.successText : Theme.errorText
+ radius: 6
+ height: resultLabel.implicitHeight + 12
+ Label {
+ id: resultLabel
+ anchors.fill: parent
+ anchors.margins: 6
+ wrapMode: Text.Wrap
+ visible: text.length > 0
+ color: root.connected ? Theme.successText : Theme.errorText
+ font.pixelSize: 11
+ }
}
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
- Button {
- text: qsTr("Отмена")
- onClicked: root.reject()
- }
+ Button { text: qsTr("Отмена"); onClicked: root.reject() }
Item { Layout.fillWidth: true }
Button {
text: qsTr("Проверить соединение")
@@ -150,36 +146,29 @@ Dialog {
onClicked: {
resultLabel.text = ""
root.connected = false
- syncManager.testConnection(root.providerIndex,
- urlField.text, userField.text,
- tokenField.text)
+ syncManager.testConnection(root.providerIndex, urlField.text, userField.text, tokenField.text)
}
}
Button {
text: qsTr("Сохранить")
+ highlighted: true
enabled: root.connected
onClicked: {
- settingsStore.addProvider(root.providerName, {
- url: urlField.text,
- user: userField.text,
- token: tokenField.text,
- })
+ settingsStore.addProvider(root.providerName, { url: urlField.text, user: userField.text, token: tokenField.text })
root.saved(root.providerIndex)
root.accept()
}
}
}
+ BusyIndicator { visible: syncManager.busy; running: syncManager.busy; Layout.alignment: Qt.AlignHCenter }
}
Connections {
target: syncManager
function onConnectionResult(provider, ok, code, title, hint, raw) {
- if (provider !== root.providerIndex)
- return
+ if (provider !== root.providerIndex) return
root.connected = ok
- resultLabel.text = ok
- ? qsTr("Соединение установлено. Нажмите «Сохранить».")
- : title + (hint && hint.length > 0 ? "\n" + hint : "")
+ resultLabel.text = ok ? qsTr("Соединение установлено. Нажмите «Сохранить».") : title + (hint && hint.length > 0 ? "\n" + hint : "")
}
}
-}
\ No newline at end of file
+}
diff --git a/qml/dialogs/TableEditor.qml b/qml/dialogs/TableEditor.qml
index b9bb692..ada6323 100644
--- a/qml/dialogs/TableEditor.qml
+++ b/qml/dialogs/TableEditor.qml
@@ -1,10 +1,8 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Редактор markdown-таблицы. По умолчанию 2×1.
-// Справа — столбец «+» (добавить строку под строкой), снизу — ряд «+»
-// (добавить столбец правее столбца). Полосы «+» растут вместе с таблицей.
Dialog {
id: root
@@ -16,44 +14,27 @@ Dialog {
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
standardButtons: Dialog.NoButton
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
property int rows: 1
property int cols: 2
- property var cells: [] // плоский список строк (rows*cols)
+ property var cells: []
readonly property int cellW: 120
readonly property int cellH: 34
- function ensureCells() {
- while (cells.length < rows * cols)
- cells.push("")
- while (cells.length > rows * cols)
- cells.pop()
- }
-
- function cell(r, c) {
- const i = r * cols + c
- return i < cells.length ? cells[i] : ""
- }
- function setCell(r, c, v) {
- const i = r * cols + c
- if (i < cells.length) cells[i] = v
- }
+ function cell(r, c) { const i = r * cols + c; return i < cells.length ? cells[i] : "" }
+ function setCell(r, c, v) { const i = r * cols + c; if (i < cells.length) cells[i] = v }
function addRowAfter(r) {
- const src = []
- for (let c = 0; c < cols; ++c) src.push(cell(r, c))
+ // Новая строка пустая, а не копия
const ins = r + 1
const flat = []
for (let rr = 0; rr <= rows; ++rr) {
- if (rr === ins) for (const v of src) flat.push(v)
- for (let c = 0; c < cols; ++c) {
- if (rr < rows) flat.push(cell(rr, c))
- }
+ if (rr === ins) for (let c = 0; c < cols; ++c) flat.push("")
+ for (let c = 0; c < cols; ++c) if (rr < rows) flat.push(cell(rr, c))
}
- cells = flat
- rows += 1
- rebuildGrid()
+ cells = flat; rows += 1
}
function addColAfter(c) {
@@ -67,115 +48,90 @@ Dialog {
if (cc < cols) flat.push(cell(rr, cc))
}
}
- cells = flat
- cols += 1
- rebuildGrid()
+ cells = flat; cols += 1
}
function buildMarkdown() {
let out = ""
- for (let c = 0; c < cols; ++c)
- out += (c === 0 ? "| " : " | ") + cell(0, c)
+ for (let c = 0; c < cols; ++c) out += (c === 0 ? "| " : " | ") + cell(0, c)
out += " |\n"
- for (let c = 0; c < cols; ++c)
- out += (c === 0 ? "| " : " | ") + "---"
+ for (let c = 0; c < cols; ++c) out += (c === 0 ? "| " : " | ") + "---"
out += " |\n"
for (let r = 1; r < rows; ++r) {
- for (let c = 0; c < cols; ++c)
- out += (c === 0 ? "| " : " | ") + cell(r, c)
+ for (let c = 0; c < cols; ++c) out += (c === 0 ? "| " : " | ") + cell(r, c)
out += " |\n"
}
return out
}
- function rebuildGrid() {
- // Repeater-ы привязаны к root.rows/root.cols напрямую — ничего не делаем.
- }
-
function resetDialog() {
- rows = 1
- cols = 2
- cells = []
+ rows = 1; cols = 2; cells = []
for (let i = 0; i < rows * cols; ++i) cells.push("")
}
- Component.onCompleted: {
- resetDialog()
- }
+ Component.onCompleted: resetDialog()
ColumnLayout {
anchors.fill: parent
- spacing: 8
+ spacing: 10
+
+ Label { text: qsTr("Заполните ячейки таблицы"); color: Theme.textSecondary; font.pixelSize: 11 }
Flickable {
Layout.fillWidth: true
Layout.fillHeight: true
- contentWidth: cellGrid.width + 26
+ contentWidth: cellGrid.width + 30
contentHeight: cellGrid.height + 30
clip: true
ScrollBar.horizontal: ScrollBar {}
ScrollBar.vertical: ScrollBar {}
-
Row {
id: tableRow
spacing: 0
-
Grid {
id: cellGrid
columns: root.cols
- columnSpacing: 2
- rowSpacing: 2
-
+ columnSpacing: 3
+ rowSpacing: 3
Repeater {
model: root.rows * root.cols
delegate: TextField {
- width: root.cellW
- height: root.cellH
+ width: root.cellW; height: root.cellH
+ placeholderText: (Math.floor(model / root.cols)===0 ? qsTr("Заголовок") : qsTr("Ячейка"))
+ placeholderTextColor: Theme.textFaint
text: root.cell(Math.floor(model / root.cols), model % root.cols)
+ color: Theme.text
+ background: Rectangle { color: Theme.bgAlt; border.color: parent.activeFocus ? Theme.accent : Theme.border; radius: 6 }
onTextEdited: root.setCell(Math.floor(model / root.cols), model % root.cols, text)
}
}
}
-
- // Полоса «+» справа: добавить строку под строкой (20px шириной)
Grid {
- columns: 1
- columnSpacing: 2
- rowSpacing: 2
+ columns: 1; columnSpacing: 3; rowSpacing: 3
anchors.leftMargin: 4
Repeater {
- id: rowAddStrip
model: root.rows
delegate: Button {
- width: 20
- height: root.cellH
+ width: 24; height: root.cellH
text: "+"
- font.pixelSize: 12
+ ToolTip.text: qsTr("Добавить строку")
ToolTip.visible: hovered
- ToolTip.text: qsTr("Добавить строку под этой")
onClicked: root.addRowAfter(model)
}
}
}
}
-
Row {
- anchors.top: tableRow.bottom
- anchors.topMargin: 4
+ anchors.top: tableRow.bottom; anchors.topMargin: 4
Grid {
- columns: root.cols
- columnSpacing: 2
- rowSpacing: 2
+ columns: root.cols; columnSpacing: 3; rowSpacing: 3
Repeater {
- id: colAddStrip
model: root.cols
delegate: Button {
- width: root.cellW
- height: 20
+ width: root.cellW; height: 24
text: "+"
- font.pixelSize: 12
+ ToolTip.text: qsTr("Добавить столбец")
ToolTip.visible: hovered
- ToolTip.text: qsTr("Добавить столбец правее этого")
onClicked: root.addColAfter(model)
}
}
@@ -186,22 +142,9 @@ Dialog {
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
- Label {
- text: qsTr("Строк: %1 Столбцов: %2").arg(root.rows).arg(root.cols)
- color: "#888"
- }
- Button {
- text: qsTr("Отмена")
- onClicked: root.reject()
- }
- Button {
- text: qsTr("Вставить")
- highlighted: true
- onClicked: {
- root.markdownReady(buildMarkdown())
- root.accept()
- }
- }
+ Label { text: qsTr("Строк: %1 Столбцов: %2").arg(root.rows).arg(root.cols); color: Theme.textMuted; font.pixelSize: 11 }
+ Button { text: qsTr("Отмена"); onClicked: root.reject() }
+ Button { text: qsTr("Вставить"); highlighted: true; onClicked: { root.markdownReady(buildMarkdown()); root.accept() } }
}
}
-}
\ No newline at end of file
+}
diff --git a/qml/panels/EditorPane.qml b/qml/panels/EditorPane.qml
index 30061da..6fa2e35 100644
--- a/qml/panels/EditorPane.qml
+++ b/qml/panels/EditorPane.qml
@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
Item {
id: pane
@@ -13,17 +14,22 @@ Item {
signal openBeside()
signal closePane()
- function insertText(t) {
- textDisplay.insert(textDisplay.cursorPosition, t)
- }
+ function insertText(t) { textDisplay.insert(textDisplay.cursorPosition, t) }
function refresh() {
const idx = pane.docIndex
if (idx >= 0) {
const c = documentManager.contentAt(idx)
- textDisplay.text = c
+ if (textDisplay.text !== c && !textDisplay.activeFocus)
+ textDisplay.text = c
+ if (!pane.editMode) {
+ viewDisplay.text = mdParser.render(c, fileStore.vault, filePathDir(documentManager.pathAt(idx)))
+ } else {
+ viewDisplay.text = ""
+ }
} else {
textDisplay.text = ""
+ viewDisplay.text = ""
}
}
@@ -37,102 +43,205 @@ Item {
Connections {
target: documentManager
- function onActiveIndexChanged() {
- if (documentManager.activeIndex === pane.docIndex)
- refresh()
- }
+ function onContentChanged(idx) { if (idx === pane.docIndex && !textDisplay.activeFocus) refresh() }
+ function onActiveIndexChanged() { if (documentManager.activeIndex === pane.docIndex) refresh() }
}
Component.onCompleted: refresh()
- // --- Текстовый редактор (сверху до низу, без шапки) ---
- TextEdit {
- id: textDisplay
+ function applyInline(pre, post) {
+ if (!pane.editMode) return
+ textDisplay.forceActiveFocus()
+ const s = textDisplay.selectionStart, e = textDisplay.selectionEnd
+ if (s !== e) {
+ const sel = textDisplay.selectedText
+ textDisplay.remove(s, e)
+ textDisplay.insert(s, pre + sel + post)
+ textDisplay.select(s, s + pre.length + sel.length + post.length)
+ } else {
+ const pos = textDisplay.cursorPosition
+ textDisplay.insert(pos, pre + post)
+ textDisplay.cursorPosition = pos + pre.length
+ }
+ documentManager.updateContent(pane.docIndex, textDisplay.text)
+ }
+ function applyHeading(level) {
+ if (!pane.editMode) return
+ textDisplay.forceActiveFocus()
+ const txt = textDisplay.text
+ const pos = textDisplay.cursorPosition
+ let start = txt.lastIndexOf("\n", pos - 1) + 1
+ let end = txt.indexOf("\n", pos)
+ if (end < 0) end = txt.length
+ let line = txt.substring(start, end)
+ line = line.replace(/^#{1,6}\s*/, "")
+ const newLine = "#".repeat(level) + " " + line
+ textDisplay.remove(start, end)
+ textDisplay.insert(start, newLine)
+ textDisplay.cursorPosition = start + newLine.length
+ documentManager.updateContent(pane.docIndex, textDisplay.text)
+ }
+ function applyLinePrefix(prefix) {
+ if (!pane.editMode) return
+ textDisplay.forceActiveFocus()
+ const txt = textDisplay.text
+ const s = textDisplay.selectionStart, e = textDisplay.selectionEnd
+ let start = txt.lastIndexOf("\n", s - 1) + 1
+ let finish = txt.indexOf("\n", e)
+ if (finish < 0) finish = txt.length
+ const block = txt.substring(start, finish)
+ const newBlock = block.split("\n").map(l => prefix + l).join("\n")
+ textDisplay.remove(start, finish)
+ textDisplay.insert(start, newBlock)
+ textDisplay.select(start, start + newBlock.length)
+ documentManager.updateContent(pane.docIndex, textDisplay.text)
+ }
+ function applyTaskToggle() { applyLinePrefix("- [ ] ") }
+ function applyCallout(kind) { applyLinePrefix("> [!" + kind + "] ") }
+ function applyFloatWrap() {
+ if (!pane.editMode) return
+ textDisplay.forceActiveFocus()
+ textDisplay.insert(textDisplay.cursorPosition, "![[image.png|float:left|width:40%]]")
+ documentManager.updateContent(pane.docIndex, textDisplay.text)
+ }
+ function applyColor(color, highlightMode) {
+ if (highlightMode) applyInline('', '')
+ else applyInline('', '')
+ }
+ function applySubSup(kind) {
+ if (kind === "sup") applyInline("", "")
+ else applyInline("", "")
+ }
+
+ // --- Текстовый редактор ---
+ ScrollView {
+ id: editScroll
anchors.fill: parent
- anchors.bottomMargin: 32
+ anchors.bottomMargin: 36
visible: pane.editMode
clip: true
- text: documentManager.activeIndex >= 0
- ? documentManager.contentAt(documentManager.activeIndex)
- : ""
- color: "black"
- font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
- font.pixelSize: 14
- wrapMode: TextEdit.NoWrap
- selectByMouse: true
- onTextEdited: documentManager.updateContent(pane.docIndex, text)
- }
-
- // --- Режим просмотра ---
- TextEdit {
- id: viewDisplay
- anchors.fill: parent
- anchors.bottomMargin: 32
- visible: !pane.editMode
- readOnly: true
- clip: true
- text: pane.editMode ? "" : mdParser.render(
- documentManager.contentAt(pane.docIndex),
- fileStore.vault,
- filePathDir(documentManager.pathAt(pane.docIndex))
- )
- textFormat: TextEdit.RichText
- color: "black"
- font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
- font.pixelSize: 14
- wrapMode: TextEdit.Wrap
- onLinkActivated: (href) => {
- if (href.startsWith("flora://task/")) {
- const line = Number(href.substring(14))
- if (line > 0) {
- const lines = textDisplay.text.split("\n")
- const ln = lines[line - 1]
- if (ln !== undefined) {
- if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
- else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
- textDisplay.text = lines.join("\n")
- documentManager.updateContent(pane.docIndex, textDisplay.text)
- }
- }
- } else if (href.startsWith("flora://open/")) {
- documentManager.openDocument(decodeURIComponent(href.substring(13)))
- }
+ ScrollBar.vertical.policy: ScrollBar.AsNeeded
+ TextArea {
+ id: textDisplay
+ placeholderText: qsTr("Начните писать…")
+ placeholderTextColor: Theme.textFaint
+ color: Theme.editorText
+ selectionColor: Theme.selectionBg
+ selectedTextColor: Theme.text
+ font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
+ font.pixelSize: 14
+ wrapMode: TextArea.NoWrap
+ selectByMouse: true
+ background: Rectangle { color: Theme.editorBg }
+ onTextChanged: if (activeFocus && text !== documentManager.contentAt(pane.docIndex)) editDebounce.restart()
}
}
+ Timer { id: editDebounce; interval: 250; onTriggered: documentManager.updateContent(pane.docIndex, textDisplay.text) }
- // --- Серая панель ВНИЗУ: кнопки ✎👁 × ---
+ ScrollView {
+ id: viewScroll
+ anchors.fill: parent
+ anchors.bottomMargin: 36
+ visible: !pane.editMode
+ clip: true
+ ScrollBar.vertical.policy: ScrollBar.AsNeeded
+ TextArea {
+ id: viewDisplay
+ readOnly: true
+ textFormat: Text.RichText
+ color: Theme.text
+ selectionColor: Theme.selectionBg
+ font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
+ font.pixelSize: 14
+ wrapMode: TextArea.Wrap
+ background: Rectangle { color: Theme.editorBg }
+ onLinkActivated: (href) => {
+ if (href.startsWith("flora://copycode/")) {
+ const id = href.substring(17)
+ const code = mdParser.getCodeBlock(id)
+ if (code.length > 0) { mdParser.copyToClipboard(code); copyFeedback.visible = true; copyTimer.restart() }
+ return
+ }
+ if (href.startsWith("flora://task/")) {
+ const line = Number(href.substring(14))
+ if (line > 0) {
+ const lines = textDisplay.text.split("\n")
+ const ln = lines[line - 1]
+ if (ln !== undefined) {
+ if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
+ else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
+ textDisplay.text = lines.join("\n")
+ documentManager.updateContent(pane.docIndex, textDisplay.text)
+ }
+ }
+ } else if (href.startsWith("flora://open/")) {
+ documentManager.openDocument(decodeURIComponent(href.substring(13)))
+ } else {
+ Qt.openUrlExternally(href)
+ }
+ }
+ }
+ // Тост копирования
+ Rectangle {
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.topMargin: 8
+ visible: copyFeedback.visible
+ color: "#333"
+ radius: 6
+ width: copyFeedback.implicitWidth + 16
+ height: copyFeedback.implicitHeight + 10
+ z: 11
+ Label { id: copyFeedback; anchors.centerIn: parent; visible: false; text: qsTr("Скопировано!"); color: "white"; font.pixelSize: 11 }
+ }
+ Timer { id: copyTimer; interval: 1500; onTriggered: copyFeedback.visible = false }
+ }
+
+ // --- Нижняя панель ---
Rectangle {
id: toolbar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
- height: 32
+ height: 36
z: 10
- color: "#e0e0e0"
- border.color: "#ccc"
+ color: Theme.toolbarBg
+ border.color: Theme.divider
RowLayout {
anchors.fill: parent
- anchors.rightMargin: 4
+ anchors.leftMargin: 8
+ anchors.rightMargin: 8
spacing: 4
-
- Item { Layout.fillWidth: true }
-
+ Label {
+ visible: pane.docIndex >= 0
+ text: pane.docIndex >= 0 ? documentManager.titleAt(pane.docIndex) : ""
+ color: Theme.textMuted
+ font.pixelSize: 11
+ elide: Text.ElideMiddle
+ Layout.fillWidth: true
+ }
ToolButton {
text: "✎"
checkable: true
checked: pane.editMode
+ ToolTip.text: qsTr("Редактирование")
+ ToolTip.visible: hovered
onClicked: pane.editMode = true
}
ToolButton {
text: "👁"
checkable: true
checked: !pane.editMode
+ ToolTip.text: qsTr("Просмотр")
+ ToolTip.visible: hovered
onClicked: pane.editMode = false
}
ToolButton {
text: "\u2715"
visible: pane.closable
+ ToolTip.text: qsTr("Закрыть")
+ ToolTip.visible: hovered
onClicked: pane.closePane()
}
}
diff --git a/qml/panels/EditorPanel.qml b/qml/panels/EditorPanel.qml
index 3137d4f..0d3c277 100644
--- a/qml/panels/EditorPanel.qml
+++ b/qml/panels/EditorPanel.qml
@@ -1,127 +1,619 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Центральная панель: вкладки сверху, тулбар, редактор-текст, панель внизу.
-// Всё — anchors, без ColumnLayout/RowLayout (SplitView не работает с ними).
Item {
id: root
property bool sideBySide: false
+ property string viewMode: "edit" // edit, preview, live — per-tab, persisted
+ property bool _loadingViewMode: false
+
+ function loadViewModeForCurrentTab() {
+ if (documentManager.activeIndex < 0 || !fileStore.ready) return
+ const path = documentManager.pathAt(documentManager.activeIndex)
+ if (!path) return
+ const saved = settingsStore.viewModeForFile(fileStore.vault, path)
+ _loadingViewMode = true
+ viewMode = saved
+ _loadingViewMode = false
+ // обновить превью если нужно
+ if (viewMode === "preview") {
+ const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ viewArea.text = rendered.trim().length === 0 ? "
" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
" : rendered
+ } else if (viewMode === "live") {
+ const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ livePreview.text = rendered.trim().length === 0 ? "" + qsTr("Предпросмотр пуст") + "
" : rendered
+ }
+ }
+
+ function saveViewModeForCurrentTab() {
+ if (_loadingViewMode) return
+ if (documentManager.activeIndex < 0 || !fileStore.ready) return
+ const path = documentManager.pathAt(documentManager.activeIndex)
+ if (!path) return
+ settingsStore.setViewModeForFile(fileStore.vault, path, viewMode)
+ }
+
+ onViewModeChanged: {
+ if (_loadingViewMode) return
+ saveViewModeForCurrentTab()
+ if (viewMode === "preview") {
+ const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ if (rendered.trim().length === 0) {
+ viewArea.text = "" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
"
+ } else {
+ viewArea.text = rendered
+ }
+ } else if (viewMode === "live") {
+ const r = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ livePreview.text = r.trim().length === 0 ? "" + qsTr("Предпросмотр пуст") + "
" : r
+ if (livePreview.text.trim().length === 0) livePreview.text = "" + qsTr("Предпросмотр пуст") + "
"
+ }
+ }
function baseName(p) {
const i = p.lastIndexOf("/")
return i >= 0 ? p.substring(i + 1) : p
}
+ function dirName(p) {
+ const i = p.lastIndexOf("/")
+ return i >= 0 ? p.substring(0, i) : ""
+ }
- // --- Вкладки (верх) ---
+ // --- Вкладки с перетаскиванием ---
Rectangle {
id: tabRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
- height: 32
- color: "#ececec"
+ height: 38
+ color: Theme.bgAlt
+ border.color: Theme.divider
+ Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider }
- RowLayout {
- anchors.fill: parent; anchors.leftMargin: 4; spacing: 2
- Repeater {
- model: documentManager.openTabs
- delegate: Rectangle {
- id: tab
- required property string modelData
- required property int index
- readonly property bool active: index === documentManager.activeIndex
- width: Math.max(90, tabLabel.implicitWidth + 32)
- height: 26; radius: 4
- color: active ? "white" : "#e0e0e0"
- MouseArea {
- anchors.fill: parent
- onClicked: documentManager.setActiveIndex(index)
- onDoubleClicked: documentManager.closeTab(index)
- }
- Label {
- id: tabLabel
- anchors.verticalCenter: parent.verticalCenter
- anchors.left: parent.left; anchors.leftMargin: 8
- text: root.baseName(modelData) + (documentManager.isDirty(index) ? " •" : "")
- font.pixelSize: 12; color: tab.active ? "#111" : "#555"
- }
- ToolButton {
- text: "\u2715"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter
- font.pixelSize: 9; width: 16
- opacity: tab.active || hovered ? 1 : 0
- onClicked: documentManager.closeTab(index)
+ ScrollView {
+ id: tabScroll
+ anchors.fill: parent
+ anchors.leftMargin: 6
+ anchors.rightMargin: 6
+ clip: true
+ ScrollBar.horizontal.policy: ScrollBar.AsNeeded
+ ScrollBar.vertical.policy: ScrollBar.AlwaysOff
+
+ Row {
+ id: tabRowLayout
+ spacing: 4
+ height: tabScroll.height
+
+ Repeater {
+ model: documentManager.openTabs
+ delegate: Rectangle {
+ id: tab
+ required property string modelData
+ required property int index
+ readonly property bool active: index === documentManager.activeIndex
+ y: 5
+ width: Math.max(120, tabLabel.implicitWidth + 40)
+ height: 28; radius: 6
+ color: active ? Theme.tabActiveBg : Theme.tabInactiveBg
+ border.color: active ? Theme.accent : (dropArea.containsDrag ? Theme.accent : "transparent")
+ border.width: active || dropArea.containsDrag ? 1 : 0
+ opacity: Drag.active ? 0.6 : 1.0
+
+ // Drag для переупорядочивания
+ Drag.active: dragHandler.active
+ Drag.source: tab
+ Drag.hotSpot.x: width/2
+ Drag.hotSpot.y: height/2
+ Drag.keys: ["tab"]
+
+ Rectangle {
+ visible: active
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 2
+ color: Theme.accent
+ radius: 1
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ anchors.rightMargin: 4
+ spacing: 4
+ Label {
+ id: tabLabel
+ Layout.fillWidth: true
+ text: root.baseName(modelData) + (documentManager.isDirty(index) ? " •" : "")
+ font.pixelSize: 12
+ color: tab.active ? Theme.text : Theme.textSecondary
+ elide: Text.ElideMiddle
+ }
+ ToolButton {
+ text: "\u2715"
+ implicitWidth: 20
+ implicitHeight: 20
+ font.pixelSize: 10
+ opacity: tab.active || hovered ? 0.9 : 0
+ visible: opacity > 0
+ onClicked: documentManager.closeTab(index)
+ ToolTip.text: qsTr("Закрыть")
+ ToolTip.visible: hovered
+ }
+ }
+
+ HoverHandler { id: hover }
+
+ // Клик — смена активной вкладки, средняя кнопка — закрыть
+ TapHandler {
+ id: tapHandler
+ acceptedButtons: Qt.LeftButton | Qt.MiddleButton
+ onTapped: (eventPoint, button) => {
+ if (button === Qt.MiddleButton) documentManager.closeTab(index)
+ else documentManager.activeIndex = index
+ }
+ }
+
+ // Перетаскивание вкладок
+ DragHandler {
+ id: dragHandler
+ target: null
+ acceptedButtons: Qt.LeftButton
+ }
+
+ DropArea {
+ id: dropArea
+ anchors.fill: parent
+ keys: ["tab"]
+ onEntered: (drag) => {
+ if (drag.source && drag.source !== tab) drag.accepted = true
+ }
+ onDropped: (drop) => {
+ if (drop.source && drop.source.index !== undefined) {
+ const from = drop.source.index
+ const to = index
+ if (from !== to) documentManager.moveTab(from, to)
+ }
+ }
+ }
}
}
}
- Item { Layout.fillWidth: true }
- Label { visible: documentManager.openTabs.length === 0; text: qsTr("Откройте файл"); color: "#999"; font.pixelSize: 12 }
- ToolButton {
- text: "\u25A1\u25A1"; checkable: true; checked: documentManager.sideBySide
- enabled: documentManager.activeIndex >= 0
- onClicked: { documentManager.setSecondaryIndex(documentManager.activeIndex); documentManager.setSideBySide(checked) }
+ }
+
+ Label {
+ visible: documentManager.openTabs.length === 0
+ anchors.centerIn: parent
+ text: qsTr("Нет открытых файлов")
+ color: Theme.textMuted
+ font.pixelSize: 12
+ font.italic: true
+ }
+
+ ToolButton {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.rightMargin: 6
+ visible: false // старый sideBySide убран в пользу live
+ }
+ }
+
+ // Хлебные крошки
+ Rectangle {
+ id: crumbs
+ anchors.top: tabRow.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: visible ? 24 : 0
+ visible: documentManager.activeIndex >= 0
+ color: Theme.bg
+ Label {
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ verticalAlignment: Text.AlignVCenter
+ text: {
+ const p = documentManager.pathAt(documentManager.activeIndex)
+ if (!p) return ""
+ const vault = fileStore.vault
+ let rel = p
+ if (vault && p.startsWith(vault)) rel = p.substring(vault.length + 1)
+ return rel
+ }
+ color: Theme.textMuted
+ font.pixelSize: 11
+ elide: Text.ElideMiddle
+ }
+ Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider; visible: viewMode !== "live" }
+ }
+
+ FormatToolbar {
+ id: fmtBar
+ anchors.top: crumbs.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 36
+ visible: documentManager.activeIndex >= 0 && viewMode !== "preview"
+ targetPane: root
+ onTableRequested: { tableEditor.resetDialog(); tableEditor.open() }
+ }
+
+ // --- Контент ---
+ SplitView {
+ id: contentSplit
+ anchors.top: fmtBar.visible ? fmtBar.bottom : crumbs.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: toolbar.top
+ orientation: Qt.Horizontal
+ handle: Rectangle {
+ implicitWidth: viewMode === "live" ? 1 : 0
+ implicitHeight: viewMode === "live" ? 1 : 0
+ color: Theme.divider
+ visible: viewMode === "live"
+ }
+
+ // Редактор — виден в edit и live
+ ScrollView {
+ id: editorScroll
+ SplitView.fillWidth: true
+ SplitView.minimumWidth: 200
+ visible: viewMode !== "preview" && documentManager.activeIndex >= 0
+ clip: true
+ ScrollBar.vertical.policy: ScrollBar.AsNeeded
+ ScrollBar.horizontal.policy: ScrollBar.AsNeeded
+
+ TextArea {
+ id: textArea
+ placeholderText: qsTr("Начните писать…")
+ placeholderTextColor: Theme.textFaint
+ color: Theme.editorText
+ selectionColor: Theme.selectionBg
+ selectedTextColor: Theme.text
+ font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
+ font.pixelSize: 14
+ wrapMode: TextArea.NoWrap
+ selectByMouse: true
+ background: Rectangle { color: Theme.editorBg }
+ onTextChanged: {
+ if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) {
+ editDebounce.restart()
+ }
+ if (viewMode === "live") liveDebounce.restart()
+ }
+ function insertText(t) { insert(cursorPosition, t) }
+ }
+ }
+
+ // Превью — виден в preview и live
+ ScrollView {
+ id: viewScroll
+ SplitView.fillWidth: true
+ SplitView.minimumWidth: 200
+ visible: viewMode !== "edit" && documentManager.activeIndex >= 0
+ clip: true
+ ScrollBar.vertical.policy: ScrollBar.AsNeeded
+
+ TextArea {
+ id: viewArea
+ visible: viewMode === "preview"
+ readOnly: true
+ textFormat: Text.RichText
+ color: Theme.text
+ selectionColor: Theme.selectionBg
+ font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
+ font.pixelSize: 14
+ wrapMode: TextArea.Wrap
+ background: Rectangle { color: Theme.editorBg }
+ onLinkActivated: (href) => handleLink(href)
+ }
+
+ // Live preview отдельный TextArea чтобы не конфликтовать с обычным preview
+ TextArea {
+ id: livePreview
+ visible: viewMode === "live"
+ readOnly: true
+ textFormat: Text.RichText
+ color: Theme.text
+ selectionColor: Theme.selectionBg
+ font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
+ font.pixelSize: 14
+ wrapMode: TextArea.Wrap
+ background: Rectangle { color: Theme.editorBg }
+ onLinkActivated: (href) => handleLink(href)
}
}
}
- // --- Тулбар (под вкладками) ---
- FormatToolbar {
- id: fmtBar
- anchors.top: tabRow.bottom; anchors.left: parent.left; anchors.right: parent.right
- height: 34
- visible: documentManager.activeIndex >= 0
- onTableRequested: { tableEditor.resetDialog(); tableEditor.open() }
- }
-
- // --- Текстовый редактор (основная область) ---
- TextEdit {
- id: textArea
- anchors.top: fmtBar.bottom
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.bottom: toolbar.top
- clip: true
- visible: documentManager.activeIndex >= 0
- text: documentManager.contentAt(documentManager.activeIndex)
- color: "black"
- font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
- font.pixelSize: 14
- wrapMode: TextEdit.NoWrap
- selectByMouse: true
- onTextEdited: documentManager.updateContent(documentManager.activeIndex, text)
- }
-
- // --- Режим просмотра ---
- TextEdit {
- id: viewArea
- anchors.top: fmtBar.bottom; anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: toolbar.top
- visible: false
- readOnly: true; clip: true
- textFormat: TextEdit.RichText
- color: "black"
- font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
- font.pixelSize: 14; wrapMode: TextEdit.Wrap
- onLinkActivated: (href) => {
- if (href.startsWith("flora://open/"))
- documentManager.openDocument(decodeURIComponent(href.substring(13)))
+ function handleLink(href) {
+ if (href.startsWith("flora://copycode/")) {
+ const id = href.substring(17)
+ const code = mdParser.getCodeBlock(id)
+ if (code.length > 0) {
+ mdParser.copyToClipboard(code)
+ copyFeedback.text = qsTr("Скопировано!")
+ copyFeedback.visible = true
+ copyTimer.restart()
+ }
+ return
+ }
+ if (href.startsWith("flora://task/")) {
+ const line = Number(href.substring(13))
+ if (line > 0) {
+ const lines = textArea.text.split("\n")
+ const ln = lines[line - 1]
+ if (ln !== undefined) {
+ if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
+ else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
+ textArea.text = lines.join("\n")
+ documentManager.updateContent(documentManager.activeIndex, textArea.text)
+ if (viewMode === "preview") viewArea.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ else if (viewMode === "live") livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ }
+ }
+ } else if (href.startsWith("flora://open/")) {
+ documentManager.openDocument(decodeURIComponent(href.substring(13)))
+ } else if (href.startsWith("flora://wiki/")) {
+ const name = decodeURIComponent(href.substring(12))
+ const p = fileStore.vault + "/" + name + ".md"
+ documentManager.openDocument(p)
+ } else {
+ Qt.openUrlExternally(href)
}
}
- // --- Серая панель ВНИЗУ (конец) ---
+ // Тост "Скопировано"
+ Rectangle {
+ id: copyToast
+ anchors.top: contentSplit.top
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.topMargin: 8
+ visible: copyFeedback.visible
+ color: "#333"
+ radius: 6
+ width: copyFeedback.implicitWidth + 16
+ height: copyFeedback.implicitHeight + 10
+ z: 10
+ Label {
+ id: copyFeedback
+ anchors.centerIn: parent
+ visible: false
+ text: qsTr("Скопировано!")
+ color: "white"
+ font.pixelSize: 11
+ }
+ }
+ Timer { id: copyTimer; interval: 1500; onTriggered: copyFeedback.visible = false }
+
+ // Пустое состояние
+ Column {
+ anchors.centerIn: contentSplit
+ visible: documentManager.activeIndex < 0
+ spacing: 12
+ Label { anchors.horizontalCenter: parent.horizontalCenter; text: "\uD83D\uDCC4"; font.pixelSize: 36; opacity: 0.6 }
+ Label { text: qsTr("Откройте файл из дерева слева"); color: Theme.textMuted; font.pixelSize: 13 }
+ Button {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: qsTr("Создать заметку")
+ enabled: fileStore.ready
+ onClicked: {
+ const err = fileStore.createFile(fileStore.vault, "Новая заметка")
+ if (err.length === 0) {
+ for (let i=0;i= 0 && fileStore.ready) {
+ const path = documentManager.pathAt(idx)
+ if (path) {
+ const saved = settingsStore.viewModeForFile(fileStore.vault, path)
+ if (saved !== viewMode) {
+ _loadingViewMode = true
+ viewMode = saved
+ _loadingViewMode = false
+ }
+ }
+ }
+ if (idx >= 0) {
+ const c = documentManager.contentAt(idx)
+ if (textArea.text !== c) {
+ const pos = textArea.cursorPosition
+ textArea.text = c
+ textArea.cursorPosition = Math.min(pos, c.length)
+ }
+ if (viewMode === "preview") {
+ const rendered = mdParser.render(c, fileStore.vault, activeFileDir())
+ viewArea.text = rendered.trim().length === 0 ? "" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
" : rendered
+ } else if (viewMode === "live") {
+ const rendered = mdParser.render(c, fileStore.vault, activeFileDir())
+ livePreview.text = rendered.trim().length === 0 ? "" + qsTr("Предпросмотр пуст") + "
" : rendered
+ }
+ } else {
+ textArea.text = ""
+ viewArea.text = ""
+ livePreview.text = ""
+ }
+ refreshGitStatus()
+ }
+ Connections {
+ target: documentManager
+ function onActiveIndexChanged() { root.loadContent() }
+ function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() }
+ function onOpenTabsChanged() { root.loadContent() }
+ function onDirtyFlagChanged() { refreshGitStatus() }
+ function onDocumentSaved() { refreshGitStatus() }
+ }
+ Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
+ Timer { id: gitPoll; interval: 2000; running: documentManager.activeIndex >= 0; repeat: true; onTriggered: refreshGitStatus() }
+ Component.onCompleted: loadContent()
+
+ function applyInline(pre, post) {
+ if (viewMode === "preview") return
+ textArea.forceActiveFocus()
+ const s = textArea.selectionStart, e = textArea.selectionEnd
+ if (s !== e) {
+ const sel = textArea.selectedText
+ textArea.remove(s, e)
+ textArea.insert(s, pre + sel + post)
+ textArea.select(s, s + pre.length + sel.length + post.length)
+ } else {
+ const pos = textArea.cursorPosition
+ textArea.insert(pos, pre + post)
+ textArea.cursorPosition = pos + pre.length
+ }
+ documentManager.updateContent(documentManager.activeIndex, textArea.text)
+ if (viewMode === "live") liveDebounce.restart()
+ }
+ function applyHeading(level) {
+ if (viewMode === "preview") return
+ textArea.forceActiveFocus()
+ const txt = textArea.text
+ const pos = textArea.cursorPosition
+ let start = txt.lastIndexOf("\n", pos - 1) + 1
+ let end = txt.indexOf("\n", pos)
+ if (end < 0) end = txt.length
+ let line = txt.substring(start, end)
+ line = line.replace(/^#{1,6}\s*/, "")
+ const prefix = "#".repeat(level) + " "
+ const newLine = prefix + line
+ textArea.remove(start, end)
+ textArea.insert(start, newLine)
+ textArea.cursorPosition = start + newLine.length
+ documentManager.updateContent(documentManager.activeIndex, textArea.text)
+ }
+ function applyLinePrefix(prefix) {
+ if (viewMode === "preview") return
+ textArea.forceActiveFocus()
+ const txt = textArea.text
+ const s = textArea.selectionStart, e = textArea.selectionEnd
+ let start = txt.lastIndexOf("\n", s - 1) + 1
+ let finish = txt.indexOf("\n", e)
+ if (finish < 0) finish = txt.length
+ const block = txt.substring(start, finish)
+ const lines = block.split("\n")
+ const newBlock = lines.map(l => prefix + l).join("\n")
+ textArea.remove(start, finish)
+ textArea.insert(start, newBlock)
+ textArea.select(start, start + newBlock.length)
+ documentManager.updateContent(documentManager.activeIndex, textArea.text)
+ }
+ function applyTaskToggle() { applyLinePrefix("- [ ] ") }
+ function applyCallout(kind) { applyLinePrefix("> [!" + kind + "] ") }
+ function applyFloatWrap() {
+ if (viewMode === "preview") return
+ textArea.forceActiveFocus()
+ textArea.insert(textArea.cursorPosition, "![[image.png|float:left|width:40%]]")
+ documentManager.updateContent(documentManager.activeIndex, textArea.text)
+ }
+ function applyColor(color, highlightMode) {
+ if (viewMode === "preview") return
+ if (highlightMode) applyInline('', '')
+ else applyInline('', '')
+ }
+ function applySubSup(kind) {
+ if (kind === "sup") applyInline("", "")
+ else applyInline("", "")
+ }
+
+ // --- Нижняя панель ---
Rectangle {
id: toolbar
anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right
- height: 32; color: "#e0e0e0"; border.color: "#ccc"
+ height: 36
+ color: Theme.toolbarBg
+ border.color: Theme.divider
RowLayout {
- anchors.fill: parent; anchors.rightMargin: 4; spacing: 4
+ anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 8; spacing: 8
+ Label {
+ visible: documentManager.activeIndex >= 0
+ text: {
+ const t = textArea.text
+ const words = t.trim().length === 0 ? 0 : t.trim().split(/\s+/).length
+ const chars = t.length
+ return qsTr("%1 слов, %2 симв.").arg(words).arg(chars)
+ }
+ color: Theme.textMuted
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
Item { Layout.fillWidth: true }
- ToolButton { text: "✎"; checkable: true; checked: documentManager.activeIndex >= 0 && !viewArea.visible; onClicked: { viewArea.visible = false; textArea.visible = true } }
- ToolButton { text: "👁"; checkable: true; checked: viewArea.visible; onClicked: {
- viewArea.visible = true; textArea.visible = false
- viewArea.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
- }}
- ToolButton { text: "\u2715"; visible: false }
+ Row {
+ visible: documentManager.activeIndex >= 0
+ spacing: 8
+ Label {
+ text: documentManager.isDirty(documentManager.activeIndex) ? qsTr("● Редактируется") : qsTr("✓ Сохранено")
+ color: documentManager.isDirty(documentManager.activeIndex) ? Theme.warningText : Theme.successText
+ font.pixelSize: 11
+ font.bold: documentManager.isDirty(documentManager.activeIndex)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Rectangle { width: 1; height: 12; color: Theme.divider; anchors.verticalCenter: parent.verticalCenter; visible: gitStatusText.length>0 }
+ Label {
+ visible: gitStatusText.length>0
+ text: "Git: " + gitStatusText
+ color: {
+ if (gitStatusText === "чисто") return Theme.textMuted
+ if (gitStatusText === "изменён" || gitStatusText === "неотслеживается" || gitStatusText === "новый") return Theme.warningText
+ if (gitStatusText === "конфликт") return Theme.errorText
+ return Theme.textMuted
+ }
+ font.pixelSize: 11
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ Item { width: 8 }
+ ButtonGroup { id: viewGroup }
+ ToolButton {
+ text: "✎"
+ checkable: true
+ checked: viewMode === "edit"
+ ButtonGroup.group: viewGroup
+ ToolTip.text: qsTr("Редактирование (Ctrl+E)")
+ ToolTip.visible: hovered
+ onClicked: viewMode = "edit"
+ }
+ ToolButton {
+ text: "\u25A8"
+ checkable: true
+ checked: viewMode === "live"
+ ButtonGroup.group: viewGroup
+ ToolTip.text: qsTr("Живой просмотр (редактор | превью)")
+ ToolTip.visible: hovered
+ onClicked: {
+ viewMode = "live"
+ livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ }
+ }
+ ToolButton {
+ text: "👁"
+ checkable: true
+ checked: viewMode === "preview"
+ ButtonGroup.group: viewGroup
+ ToolTip.text: qsTr("Просмотр (Ctrl+E)")
+ ToolTip.visible: hovered
+ onClicked: {
+ const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ viewArea.text = rendered.trim().length === 0 ? "" + qsTr("Пустой файл") + "
" : rendered
+ viewMode = "preview"
+ }
+ }
}
}
@@ -131,10 +623,21 @@ Item {
return i >= 0 ? p.substring(0, i) : ""
}
- TableEditor { id: tableEditor; onMarkdownReady: (md) => textArea.insert(textArea.cursorPosition, md) }
+ TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } }
- Shortcut { sequence: "Ctrl+S"; onActivated: documentManager.saveAll() }
+ Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
- Shortcut { sequence: "Ctrl+E"; onActivated: { viewArea.visible = !viewArea.visible; textArea.visible = !viewArea.visible } }
- Shortcut { sequence: "Ctrl+Tab"; onActivated: { const n = documentManager.openTabs.length; if (n > 0) documentManager.setActiveIndex((documentManager.activeIndex + 1) % n) } }
-}
\ No newline at end of file
+ Shortcut { sequence: "Ctrl+E"; onActivated: {
+ if (viewMode === "edit") {
+ const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+ viewArea.text = rendered.trim().length === 0 ? "" + qsTr("Пустой файл") + "
" : rendered
+ viewMode = "preview"
+ } else if (viewMode === "preview") {
+ viewMode = "edit"
+ } else {
+ viewMode = "edit"
+ }
+ } }
+ Shortcut { sequence: "Ctrl+Tab"; onActivated: { const n=documentManager.openTabs.length; if(n>0) documentManager.activeIndex = (documentManager.activeIndex+1)%n } }
+ Shortcut { sequence: "Ctrl+Shift+Tab"; onActivated: { const n=documentManager.openTabs.length; if(n>0) documentManager.activeIndex = (documentManager.activeIndex-1+n)%n } }
+}
diff --git a/qml/panels/FileTreePanel.qml b/qml/panels/FileTreePanel.qml
index d21d8c9..066fabd 100644
--- a/qml/panels/FileTreePanel.qml
+++ b/qml/panels/FileTreePanel.qml
@@ -1,10 +1,8 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Левая панель: файловое дерево хранилища (Фаза 3).
-// Создание/переименование/удаление/перемещение, иконки, стрелки раскрытия,
-// отступы подпапок, ПКМ, drag&drop. Кнопка хранилища внизу слева.
Item {
id: root
@@ -16,26 +14,33 @@ Item {
return idx >= 0 ? p.substring(idx + 1) : p
}
- // ----- Заголовок: «+» + название -----
+ property string filterText: ""
+
+ // ----- Заголовок -----
Rectangle {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
- height: 36
- color: "whitesmoke"
+ 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 }
RowLayout {
anchors.fill: parent
- anchors.leftMargin: 4
- anchors.rightMargin: 4
- spacing: 4
+ anchors.leftMargin: 8
+ anchors.rightMargin: 8
+ anchors.topMargin: 6
+ anchors.bottomMargin: 6
+ spacing: 6
Button {
id: addButton
text: "+"
- implicitWidth: 28
- implicitHeight: 26
+ implicitWidth: 32
+ implicitHeight: 28
enabled: fileStore.ready
ToolTip.visible: hovered
ToolTip.text: qsTr("Создать файл или папку")
@@ -46,38 +51,99 @@ Item {
text: fileStore.ready ? qsTr("Файлы") : qsTr("Хранилище не выбрано")
Layout.fillWidth: true
elide: Text.ElideRight
- color: "#555"
+ 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()
}
}
}
+ // ----- Поле поиска -----
Rectangle {
+ id: searchBar
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
- anchors.bottom: vaultButton.top
- color: "#fafafa"
+ 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
+ anchors.bottom: vaultButton.top
+ color: Theme.bg
+
+ // ПКМ по пустому месту
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton)
- addMenu.x = mouse.x; addMenu.y = mouse.y; addMenu.popup()
+ addMenu.popup()
}
}
ListView {
id: treeView
anchors.fill: parent
+ anchors.topMargin: 4
clip: true
model: fileStore
currentIndex: -1
focus: true
- ScrollBar.vertical: ScrollBar {}
+ ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
+ spacing: 1
- // Перемещение на пустое место = в корень хранилища
DropArea {
anchors.fill: parent
onDropped: (drop) => {
@@ -99,31 +165,46 @@ Item {
required property string icon
required property int index
+ // Фильтрация: показываем только совпадающие; папки с детьми не показываем автоматически чтобы не захламлять
+ readonly property bool matchesFilter: root.filterText.length === 0 || display.toLowerCase().indexOf(root.filterText.toLowerCase()) !== -1
+ visible: matchesFilter
+ height: matchesFilter ? 32 : 0
width: treeView.width
- height: 30
- color: rowRect.dragging ? "#d6e4ff"
- : (treeView.currentIndex === index ? "#e6f0ff"
- : (index % 2 === 0 ? "#fafafa" : "#f4f4f4"))
+ 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
property string dragPath: ""
property bool dragging: false
RowLayout {
anchors.fill: parent
- anchors.leftMargin: 4 + depth * 16
- anchors.rightMargin: 6
- spacing: 4
+ anchors.leftMargin: 6 + depth * 14
+ anchors.rightMargin: 8
+ spacing: 6
- Text {
- text: isDir ? (expanded ? "\u25BE" : "\u25B8") : ""
- font.pixelSize: 12
- Layout.preferredWidth: isDir ? 16 : 8
- horizontalAlignment: Text.AlignHCenter
- color: "#666"
- opacity: hasChildren ? 1 : 0.35
+ // Стрелка
+ 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
+ }
MouseArea {
anchors.fill: parent
- visible: hasChildren
+ enabled: hasChildren
+ cursorShape: Qt.PointingHandCursor
onClicked: fileStore.toggleExpand(index)
}
}
@@ -137,30 +218,45 @@ Item {
text: display
Layout.fillWidth: true
elide: Text.ElideRight
- color: isDir ? "#1a1a1a" : "#333"
+ color: isDir ? Theme.text : Theme.textSecondary
font.bold: isDir
+ 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
+ }
}
}
+ HoverHandler { id: hover }
+
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
- onPressed: {
- treeView.currentIndex = index
- }
+ 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)
+ if (isDir) fileStore.toggleExpand(index)
+ else root.openFileRequested(path)
}
onPressAndHold: (mouse) => {
if (mouse.button !== Qt.LeftButton || rowRect.dragPath.length > 0) return
@@ -169,20 +265,15 @@ Item {
}
}
- // Перетаскивание элемента
Drag.active: rowRect.dragging
Drag.source: rowRect
Drag.hotSpot: Qt.point(width / 2, height / 2)
- Drag.keys: [ "application/x-flora" ]
+ Drag.keys: ["application/x-flora"]
Drag.onDragFinished: rowRect.finishDrag()
Drag.onActiveChanged: if (!Drag.active) rowRect.finishDrag()
- function finishDrag() {
- dragging = false
- dragPath = ""
- }
+ function finishDrag() { dragging = false; dragPath = "" }
- // Приём перетаскивания в папку
DropArea {
anchors.fill: parent
onEntered: (drag) => { drag.accepted = rowRect.isDir }
@@ -209,88 +300,92 @@ Item {
}
}
- Label {
+ Column {
anchors.centerIn: parent
visible: !fileStore.ready
- text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
- horizontalAlignment: Text.AlignHCenter
- color: "#bbb"
+ 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
}
}
- // Кнопка хранилища внизу слева
Button {
id: vaultButton
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
+ anchors.margins: 6
+ height: 36
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()
- }
+ 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
+ anchors.bottom: vaultButton.top
+ anchors.bottomMargin: 6
+ height: 28
visible: statusText.length > 0
- color: statusText.startsWith("✓") ? "#e5f6e5" : "#fdeaea"
+ color: statusText.startsWith("✓") ? Theme.successBg : Theme.errorBg
+ border.color: statusText.startsWith("✓") ? Theme.successText : Theme.errorText
+ radius: 6
Label {
anchors.fill: parent
- anchors.margins: 6
+ anchors.leftMargin: 8
+ anchors.rightMargin: 8
+ verticalAlignment: Text.AlignVCenter
text: root.statusText
elide: Text.ElideRight
font.pixelSize: 11
- color: statusText.startsWith("✓") ? "#256029" : "#9c1c1c"
+ color: statusText.startsWith("✓") ? Theme.successText : Theme.errorText
}
}
- // ----- Меню «+» (файл/папка) -----
Menu {
id: addMenu
- width: 150
+ width: 180
MenuItem {
text: qsTr("Файл")
- onClicked: {
- nameDialog.dialogTitle = qsTr("Новый файл")
- nameDialog.actionLabel = qsTr("Создать")
- nameDialog.initialText = ""
- nameDialog.open()
- pendingCreate.kind = "file"
- }
+ onClicked: { nameDialog.dialogTitle = qsTr("Новый файл"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "file" }
}
MenuItem {
text: qsTr("Папка")
- onClicked: {
- nameDialog.dialogTitle = qsTr("Новая папка")
- nameDialog.actionLabel = qsTr("Создать")
- nameDialog.initialText = ""
- nameDialog.open()
- pendingCreate.kind = "folder"
- }
+ 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()
- }
-
+ function openFor(path, isDir) { targetPath = path; targetIsDir = isDir; popup() }
MenuItem {
text: qsTr("Открыть")
onClicked: {
@@ -302,71 +397,28 @@ Item {
}
}
}
- 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
- }
- }
+ 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 { }
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"
- }
+ 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
+ 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))
+ 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
- }
+ function rowIndexOf(p) { const n = fileStore.rowCount(); for (let i=0;i=0 && fileStore.itemIsDir(treeView.currentIndex)) return fileStore.itemPath(treeView.currentIndex); return fileStore.vault }
property var renameTarget: ({ path: "" })
property var deleteTarget: ({ path: "" })
@@ -376,26 +428,13 @@ Item {
id: nameDialog
onConfirmed: (name) => {
let err = ""
- 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)
- pendingCreate.parent = ""
+ 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=""
}
}
-
- ConfirmDialog {
- id: deleteDialog
- onConfirmed: {
- const err = fileStore.removeItem(deleteTarget.path)
- if (err.length > 0) showStatus(err, false)
- }
- }
-}
\ No newline at end of file
+ ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else showStatus(qsTr("Удалено"), true) } }
+}
diff --git a/qml/panels/FormatToolbar.qml b/qml/panels/FormatToolbar.qml
index 93185f1..9362d11 100644
--- a/qml/panels/FormatToolbar.qml
+++ b/qml/panels/FormatToolbar.qml
@@ -1,68 +1,69 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Панель быстрого форматирования текста (видна в режиме редактирования).
-// Работает с выделенным текстом в активной панели (targetPane).
Item {
id: toolbar
- property var targetPane: null // EditorPane
- readonly property bool usable: targetPane && targetPane.docIndex >= 0
+ property var targetPane: null
+ readonly property bool usable: targetPane !== null
signal tableRequested()
- implicitHeight: 34
+ implicitHeight: 38
Rectangle {
anchors.fill: parent
- color: "#f3f3f3"
- border.color: "#ddd"
+ color: Theme.toolbarBg
+ border.color: Theme.divider
- RowLayout {
+ ScrollView {
anchors.fill: parent
- anchors.leftMargin: 4
- anchors.rightMargin: 4
- spacing: 2
+ anchors.leftMargin: 6
+ anchors.rightMargin: 6
+ clip: true
+ ScrollBar.horizontal.policy: ScrollBar.AsNeeded
+ ScrollBar.vertical.policy: ScrollBar.AlwaysOff
- TbButton { text: "B"; tip: qsTr("Жирный"); onTapped: targetPane.applyInline("**", "**") }
- TbButton { text: "I"; tip: qsTr("Курсив"); onTapped: targetPane.applyInline("*", "*") }
- TbButton { text: "U"; tip: qsTr("Подчёркнутый"); onTapped: targetPane.applyInline("", "") }
- TbButton { text: "S"; tip: qsTr("Зачёркнутый"); onTapped: targetPane.applyInline("~~", "~~") }
- TbButton { text: "=="; tip: qsTr("Выделение текста"); onTapped: targetPane.applyInline("==", "==") }
+ RowLayout {
+ height: parent.height
+ spacing: 2
- TbButton {
- text: "A"
- tip: qsTr("Цвет текста")
- bold: true
- onTapped: { colorPopup.highlightMode = false; colorPopup.openAt(this) }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "B"; tip: qsTr("Жирный (Ctrl+B)"); onTapped: targetPane.applyInline("**", "**") }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "I"; tip: qsTr("Курсив (Ctrl+I)"); onTapped: targetPane.applyInline("*", "*") }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "U"; tip: qsTr("Подчёркнутый"); onTapped: targetPane.applyInline("", "") }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "S"; tip: qsTr("Зачёркнутый"); onTapped: targetPane.applyInline("~~", "~~") }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "=="; tip: qsTr("Выделение"); onTapped: targetPane.applyInline("==", "==") }
+
+ TbButton {
+ enabled: usable; opacity: enabled?1:0.4
+ text: "A"; tip: qsTr("Цвет текста"); bold: true
+ onTapped: { colorPopup.highlightMode = false; colorPopup.openAt(this) }
+ }
+ TbButton {
+ enabled: usable; opacity: enabled?1:0.4
+ text: "A"; tip: qsTr("Цвет выделения"); accent: true
+ onTapped: { colorPopup.highlightMode = true; colorPopup.openAt(this) }
+ }
+
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "x²"; tip: qsTr("Верхний индекс"); onTapped: targetPane.applySubSup("sup") }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "x₂"; tip: qsTr("Нижний индекс"); onTapped: targetPane.applySubSup("sub") }
+
+ Item { width: 8 }
+ Rectangle { width: 1; height: 20; color: Theme.divider }
+ Item { width: 8 }
+
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "H#"; tip: qsTr("Заголовок"); onTapped: headingMenu.popup(this) }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "☰"; tip: qsTr("Списки"); onTapped: listMenu.popup(this) }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "❝"; tip: qsTr("Цитата"); onTapped: targetPane.applyLinePrefix("> ") }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "⚠"; tip: qsTr("Выноска"); onTapped: calloutMenu.popup(this) }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "▦"; tip: qsTr("Таблица"); onTapped: toolbar.tableRequested() }
+ TbButton { enabled: usable; opacity: enabled?1:0.4; text: "⊡"; tip: qsTr("Обтекание"); onTapped: targetPane.applyFloatWrap() }
}
- TbButton {
- text: "A"
- tip: qsTr("Цвет выделения")
- accent: true
- onTapped: { colorPopup.highlightMode = true; colorPopup.openAt(this) }
- }
-
- TbButton { text: "x²"; tip: qsTr("Верхний индекс"); onTapped: targetPane.applySubSup("sup") }
- TbButton { text: "x₂"; tip: qsTr("Нижний индекс"); onTapped: targetPane.applySubSup("sub") }
-
- Item { width: 6 }
- Rectangle { width: 1; height: 20; color: "#ccc" }
- Item { width: 6 }
-
- TbButton { text: "H#"; tip: qsTr("Заголовок 1–6"); onTapped: headingMenu.popup() }
- TbButton { text: "☰"; tip: qsTr("Списки"); onTapped: listMenu.popup() }
- TbButton { text: "❝"; tip: qsTr("Цитата"); onTapped: targetPane.applyLinePrefix("> ") }
- TbButton { text: "⚠"; tip: qsTr("Выноска"); onTapped: calloutMenu.popup() }
- TbButton { text: "▦"; tip: qsTr("Таблица"); onTapped: toolbar.tableRequested() }
- TbButton { text: "⊡"; tip: qsTr("Обтекание картинки/таблицы"); onTapped: targetPane.applyFloatWrap() }
-
- Item { Layout.fillWidth: true }
}
}
- // ---------- Меню: заголовки ----------
Menu {
id: headingMenu
title: qsTr("Заголовок")
@@ -70,43 +71,28 @@ Item {
model: [1, 2, 3, 4, 5, 6]
MenuItem {
text: "H" + modelData + " — " + "######".slice(0, modelData)
- onClicked: targetPane.applyHeading(modelData)
+ onTriggered: targetPane.applyHeading(modelData)
}
}
}
- // ---------- Меню: списки ----------
Menu {
id: listMenu
title: qsTr("Список")
- MenuItem {
- text: qsTr("Маркированный")
- onClicked: targetPane.applyLinePrefix("- ")
- }
- MenuItem {
- text: qsTr("Нумерованный")
- onClicked: targetPane.applyLinePrefix("1. ")
- }
- MenuItem {
- text: qsTr("Задачи (- [ ])")
- onClicked: targetPane.applyTaskToggle()
- }
+ MenuItem { text: qsTr("Маркированный"); onTriggered: targetPane.applyLinePrefix("- ") }
+ MenuItem { text: qsTr("Нумерованный"); onTriggered: targetPane.applyLinePrefix("1. ") }
+ MenuItem { text: qsTr("Задачи (- [ ])"); onTriggered: targetPane.applyTaskToggle() }
}
- // ---------- Меню: выноски ----------
Menu {
id: calloutMenu
title: qsTr("Выноска")
Repeater {
model: ["NOTE", "INFO", "WARN", "ERROR"]
- MenuItem {
- text: modelData
- onClicked: targetPane.applyCallout(modelData)
- }
+ MenuItem { text: modelData; onTriggered: targetPane.applyCallout(modelData) }
}
}
- // ---------- Палитра цветов ----------
property var colorList: [
"#000000", "#ffffff", "#9e9e9e", "#795548", "#607d8b",
"#f44336", "#ff9800", "#ffeb3b", "#4caf50", "#00bcd4",
@@ -116,35 +102,29 @@ Item {
Popup {
id: colorPopup
property bool highlightMode: false
- function openAt(btn) {
- x = btn.mapToItem(toolbar, 0, 0).x
- y = btn.mapToItem(toolbar, 0, 0).y + btn.height
- open()
- }
- width: grid.width + 12
- height: grid.height + 12
+ function openAt(btn) { x = btn.mapToItem(toolbar, 0, 0).x; y = btn.mapToItem(toolbar, 0, 0).y + btn.height + 4; open() }
+ width: grid.width + 16
+ height: grid.height + 16
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 8 }
Grid {
id: grid
anchors.centerIn: parent
columns: 7
- spacing: 3
+ spacing: 4
Repeater {
model: toolbar.colorList
delegate: Rectangle {
- width: 22
- height: 22
- radius: 3
+ width: 24; height: 24; radius: 6
color: modelData
- border.color: modelData === "#ffffff" ? "#bbb" : "transparent"
+ border.color: modelData === "#ffffff" ? Theme.borderStrong : "transparent"
+ border.width: 1
MouseArea {
anchors.fill: parent
- onClicked: {
- toolbar.targetPane.applyColor(modelData, colorPopup.highlightMode)
- colorPopup.close()
- }
+ cursorShape: Qt.PointingHandCursor
+ onClicked: { toolbar.targetPane.applyColor(modelData, colorPopup.highlightMode); colorPopup.close() }
}
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/qml/panels/GraphView.qml b/qml/panels/GraphView.qml
index a1a1771..d84e41c 100644
--- a/qml/panels/GraphView.qml
+++ b/qml/panels/GraphView.qml
@@ -1,21 +1,21 @@
import QtQuick
+import Flora
-// Небольшое полотно графа связей: узлы-файлы (синие) и теги (зелёные),
-// рёбра — связи; узлы можно перетаскивать, клик по файлу открывает его.
Canvas {
id: g
- property var nodes: [] // [{key,name,type}]
- property var edges: [] // [{from,to}]
+ property var nodes: []
+ property var edges: []
signal fileClicked(string path)
- property var pos: ({}) // key -> {x,y}
+ property var pos: ({})
property string dragKey: ""
property real zoom: 1.0
property real panX: 0
property real panY: 0
property real lastMouseX: 0
property real lastMouseY: 0
+ property bool isEmpty: nodes.length === 0
function nodeForKey(key) {
for (let i = 0; i < nodes.length; ++i)
@@ -24,15 +24,71 @@ Canvas {
}
function relayout() {
- pos = {}
const n = nodes.length
- if (n === 0) { requestPaint(); return }
+ if (n === 0) { pos = {}; requestPaint(); return }
+ if (n > 600) {
+ // Слишком много узлов — показываем упрощённо без симуляции чтобы не заморозить UI
+ const cx = width / 2, cy = height / 2
+ const rad = Math.min(width, height) / 2 - 36
+ let newPos = {}
+ for (let i = 0; i < n; ++i) {
+ const a = (i / n) * 2 * Math.PI - Math.PI / 2
+ newPos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
+ }
+ pos = newPos; zoom = 1.0; panX = 0; panY = 0; requestPaint(); return
+ }
const cx = width / 2, cy = height / 2
- const rad = Math.min(width, height) / 2 - 30
+ const rad = Math.min(width, height) / 2 - 36
+ let newPos = {}
for (let i = 0; i < n; ++i) {
const a = (i / n) * 2 * Math.PI - Math.PI / 2
- pos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
+ // небольшой джиттер чтобы не накладывались ровно
+ const jitter = (Math.random() - 0.5) * 8
+ newPos[nodes[i].key] = { x: cx + rad * Math.cos(a) + jitter, y: cy + rad * Math.sin(a) + jitter }
}
+ const iterCount = n > 200 ? 25 : (n > 100 ? 40 : 80)
+ // простая force-симуляция для разведения узлов (репульсия + притяжение по рёбрам)
+ for (let iter = 0; iter < iterCount; ++iter) {
+ // репульсия между всеми парами
+ for (let i = 0; i < n; ++i) {
+ for (let j = i + 1; j < n; ++j) {
+ const a = newPos[nodes[i].key]
+ const b = newPos[nodes[j].key]
+ let dx = a.x - b.x, dy = a.y - b.y
+ let dist = Math.sqrt(dx*dx + dy*dy)
+ if (dist < 1) dist = 1
+ if (dist < 70) {
+ const force = (70 - dist) * 0.08
+ const nx = dx / dist * force
+ const ny = dy / dist * force
+ a.x += nx; a.y += ny
+ b.x -= nx; b.y -= ny
+ }
+ }
+ }
+ // притяжение по рёбрам
+ for (let e = 0; e < edges.length; ++e) {
+ const f = newPos[edges[e].from]
+ const t = newPos[edges[e].to]
+ if (!f || !t) continue
+ let dx = t.x - f.x, dy = t.y - f.y
+ let dist = Math.sqrt(dx*dx + dy*dy)
+ if (dist < 1) continue
+ const target = 90
+ const force = (dist - target) * 0.02
+ const nx = dx / dist * force
+ const ny = dy / dist * force
+ f.x += nx; f.y += ny
+ t.x -= nx; t.y -= ny
+ }
+ // удержание в границах
+ for (let k in newPos) {
+ const p = newPos[k]
+ p.x = Math.max(30, Math.min(width - 30, p.x))
+ p.y = Math.max(30, Math.min(height - 30, p.y))
+ }
+ }
+ pos = newPos
zoom = 1.0; panX = 0; panY = 0
requestPaint()
}
@@ -41,6 +97,17 @@ Canvas {
onWidthChanged: relayout()
onHeightChanged: relayout()
+ // пустое состояние
+ Text {
+ anchors.centerIn: parent
+ visible: g.isEmpty
+ text: qsTr("Нет связей\nДобавьте [[ссылки]] или #теги")
+ color: Theme.textMuted
+ horizontalAlignment: Text.AlignHCenter
+ font.pixelSize: 11
+ lineHeight: 1.3
+ }
+
function paintCanvas() {
const ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
@@ -51,9 +118,8 @@ Canvas {
ctx.scale(zoom, zoom)
ctx.translate(-width / 2, -height / 2)
- // рёбра
ctx.lineWidth = 1 / zoom
- ctx.strokeStyle = "#b9c6d6"
+ ctx.strokeStyle = Theme.graphEdge
for (let i = 0; i < edges.length; ++i) {
const f = pos[edges[i].from]
const t = pos[edges[i].to]
@@ -64,7 +130,6 @@ Canvas {
ctx.stroke()
}
- // узлы
ctx.textAlign = "center"
for (let i = 0; i < nodes.length; ++i) {
const nd = nodes[i]
@@ -74,13 +139,16 @@ Canvas {
const r = isFile ? 6 : 5
ctx.beginPath()
ctx.arc(p.x, p.y, r, 0, 2 * Math.PI)
- ctx.fillStyle = isFile ? (nd.key === g.dragKey ? "#1a6ce0" : "#3f7fd0")
- : "#3aa06a"
+ if (isFile) {
+ ctx.fillStyle = (nd.key === g.dragKey) ? Theme.accentHover : Theme.graphNodeFile
+ } else {
+ ctx.fillStyle = Theme.graphNodeTag
+ }
ctx.fill()
- ctx.strokeStyle = "#ffffff"
- ctx.lineWidth = 1
+ ctx.strokeStyle = Theme.bg
+ ctx.lineWidth = 1.5
ctx.stroke()
- ctx.fillStyle = "#5a6a7d"
+ ctx.fillStyle = Theme.graphLabel
ctx.font = (9 / zoom) + "px sans-serif"
const txt = nd.name.length > 14 ? nd.name.substring(0, 13) + "…" : nd.name
ctx.fillText(txt, p.x, p.y + r + 10 / zoom)
@@ -94,6 +162,8 @@ Canvas {
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
+ hoverEnabled: true
+ cursorShape: g.dragKey.length > 0 ? Qt.ClosedHandCursor : Qt.ArrowCursor
onWheel: (w) => {
const factor = w.angleDelta.y > 0 ? 1.15 : 0.87
g.zoom = Math.max(0.2, Math.min(5.0, g.zoom * factor))
@@ -108,18 +178,16 @@ Canvas {
for (const key in g.pos) {
const p = g.pos[key]
const dx = gx - p.x, dy = gy - p.y
- if (dx * dx + dy * dy <= 256) {
- g.dragKey = key
- break
- }
+ if (dx * dx + dy * dy <= 256) { g.dragKey = key; break }
}
}
onPositionChanged: (m) => {
+ if (!pressed) return
if (g.dragKey.length > 0 && g.pos[g.dragKey]) {
const gx = (m.x - width / 2 - panX) / zoom + width / 2
const gy = (m.y - height / 2 - panY) / zoom + height / 2
g.pos[g.dragKey] = { x: gx, y: gy }
- } else {
+ } else if (pressed) {
g.panX += m.x - g.lastMouseX
g.panY += m.y - g.lastMouseY
}
@@ -128,24 +196,23 @@ Canvas {
g.requestPaint()
}
onReleased: (m) => {
- if (g.dragKey.length === 0) {
- const gx = (m.x - width / 2 - panX) / zoom + width / 2
- const gy = (m.y - height / 2 - panY) / zoom + height / 2
+ const wasDrag = g.dragKey.length > 0
+ const gx = (m.x - width / 2 - panX) / zoom + width / 2
+ const gy = (m.y - height / 2 - panY) / zoom + height / 2
+ if (!wasDrag) {
for (const key in g.pos) {
const p = g.pos[key]
const dx = gx - p.x, dy = gy - p.y
if (dx * dx + dy * dy <= 144) {
const nd = g.nodeForKey(key)
- if (nd && nd.type === "file")
- g.fileClicked(nd.key)
+ if (nd && nd.type === "file") g.fileClicked(nd.key)
break
}
}
}
g.dragKey = ""
+ g.requestPaint()
}
- onDoubleClicked: {
- g.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout()
- }
+ onDoubleClicked: { g.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout() }
}
-}
\ No newline at end of file
+}
diff --git a/qml/panels/MonthCalendar.qml b/qml/panels/MonthCalendar.qml
index e486c0c..e0a9242 100644
--- a/qml/panels/MonthCalendar.qml
+++ b/qml/panels/MonthCalendar.qml
@@ -1,9 +1,8 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Простой календарь: шапка (месяц/год, ←/→), сетка дней.
-// Клик по дню → onDaySelected("YYYY-MM-DD").
Item {
id: cal
@@ -11,7 +10,7 @@ Item {
property date currentMonth: new Date()
readonly property int year: currentMonth.getFullYear()
- readonly property int month: currentMonth.getMonth() // 0..11
+ readonly property int month: currentMonth.getMonth()
function fmtDate(d) {
function pad(n) { return n < 10 ? "0" + n : "" + n }
@@ -20,36 +19,47 @@ Item {
function gridModel() {
const first = new Date(year, month, 1)
- const startDow = first.getDay() // 0=вс
+ const dowSun0 = first.getDay()
+ const startDow = (dowSun0 + 6) % 7
const daysInMonth = new Date(year, month + 1, 0).getDate()
const list = []
- for (let i = 0; i < startDow; ++i)
- list.push(0)
- for (let d = 1; d <= daysInMonth; ++d)
- list.push(d)
+ for (let i = 0; i < startDow; ++i) list.push(0)
+ for (let d = 1; d <= daysInMonth; ++d) list.push(d)
+ while (list.length % 7 !== 0) list.push(0)
+ while (list.length < 42) list.push(0)
return list
}
ColumnLayout {
anchors.fill: parent
- spacing: 2
+ spacing: 4
RowLayout {
Layout.fillWidth: true
ToolButton {
- text: "<"
+ text: "‹"
+ font.pixelSize: 16
onClicked: cal.currentMonth = new Date(year, month - 1, 1)
}
Label {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
font.bold: true
+ color: Theme.text
text: Qt.locale().monthName(month, Locale.LongFormat) + " " + year
}
ToolButton {
- text: ">"
+ text: "›"
+ font.pixelSize: 16
onClicked: cal.currentMonth = new Date(year, month + 1, 1)
}
+ ToolButton {
+ text: "\u2302"
+ font.pixelSize: 12
+ ToolTip.text: qsTr("Сегодня")
+ ToolTip.visible: hovered
+ onClicked: cal.currentMonth = new Date()
+ }
}
RowLayout {
@@ -60,7 +70,8 @@ Item {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
font.pixelSize: 10
- color: "#888"
+ font.bold: true
+ color: Theme.textMuted
text: modelData
}
}
@@ -70,57 +81,61 @@ Item {
Layout.fillWidth: true
Layout.fillHeight: true
columns: 7
- rowSpacing: 1
- columnSpacing: 1
+ rowSpacing: 2
+ columnSpacing: 2
Repeater {
model: cal.gridModel()
delegate: Rectangle {
required property int modelData
- width: cal.width / 7 - 2
- height: cal.height / 10 - 1
- radius: 3
- color: modelData > 0 && modelData === now.day ? "#dbeafe"
- : (modelData > 0 ? "#ffffff" : "transparent")
-
- readonly property var now: {
- const t = new Date()
- return { day: t.getDate(), month: t.getMonth(), year: t.getFullYear() }
+ width: (cal.width - 12) / 7
+ height: (cal.height - 50) / 6
+ radius: 6
+ border.width: isToday ? 1 : 0
+ border.color: Theme.accent
+ color: {
+ if (modelData <= 0) return "transparent"
+ if (isToday) return Theme.calendarToday
+ if (isSelected) return Theme.accentMuted
+ if (hasNote) return Theme.isDark ? "#252a33" : "#f8f9fb"
+ return Theme.surface
}
- // Проверяем, есть ли заметка за этот день
+ readonly property var now: { const t = new Date(); return { day: t.getDate(), month: t.getMonth(), year: t.getFullYear() } }
+ readonly property bool isToday: modelData > 0 && now.day === modelData && now.month === cal.month && now.year === cal.year
+ readonly property string ds: modelData > 0 ? Qt.formatDate(new Date(cal.year, cal.month, modelData), "yyyy-MM-dd") : ""
readonly property bool hasNote: {
- // Явная зависимость от currentMonth — пересчитываем при смене месяца
const _ = cal.currentMonth
if (modelData <= 0 || !fileStore.ready) return false
- const ds = Qt.formatDate(new Date(cal.year, cal.month, modelData), "yyyy-MM-dd")
return fileStore.fileExists(fileStore.vault + "/Журнал/" + ds + ".md")
}
+ readonly property bool isSelected: false // можно подсветить выбранный день
Label {
id: dayLabel
anchors.centerIn: parent
+ anchors.verticalCenterOffset: hasNote ? -3 : 0
visible: modelData > 0
text: modelData
font.pixelSize: 11
- color: modelData > 0 ? "#222" : "#ddd"
+ font.bold: isToday
+ color: modelData > 0 ? (isToday ? Theme.accentText : Theme.text) : "transparent"
}
- // Точка — есть заметка за этот день (под цифрой)
Rectangle {
visible: modelData > 0 && parent.hasNote
- width: 5
- height: 5
- radius: 3
- color: "#3b82f6"
+ width: 5; height: 5; radius: 2.5
+ color: Theme.calendarDot
anchors.top: dayLabel.bottom
- anchors.topMargin: 1
+ anchors.topMargin: 2
anchors.horizontalCenter: parent.horizontalCenter
}
MouseArea {
anchors.fill: parent
- visible: modelData > 0
+ enabled: modelData > 0
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
onClicked: {
const d = new Date(cal.year, cal.month, modelData)
cal.daySelected(fmtDate(d))
@@ -129,5 +144,13 @@ Item {
}
}
}
+ // подпись
+ Label {
+ Layout.fillWidth: true
+ text: qsTr("Клик — заметка дня")
+ color: Theme.textFaint
+ font.pixelSize: 9
+ horizontalAlignment: Text.AlignHCenter
+ }
}
-}
\ No newline at end of file
+}
diff --git a/qml/panels/RightPanel.qml b/qml/panels/RightPanel.qml
index 497d200..cd7772e 100644
--- a/qml/panels/RightPanel.qml
+++ b/qml/panels/RightPanel.qml
@@ -1,221 +1,309 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
+import Flora
-// Правая панель: календарь (заметки дня), «Ссылки и теги» активного файла,
-// граф связей (файлы + теги, рёбра по ссылкам).
Item {
id: root
- function activePath() {
- return documentManager.pathAt(documentManager.activeIndex)
- }
+ 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 })
- }
+ 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] })
+ for (let i = 0; i < tags.length; ++i) tagsModel.append({ tag: tags[i] })
}
- Component.onCompleted: {
- if (fileStore.ready)
- vaultIndex.rebuild(fileStore.vault)
- }
+ 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)
- }
+ function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
+ function onReadyChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
}
Connections {
target: documentManager
function onActiveIndexChanged() { root.refreshLinksTags() }
function onOpenTabsChanged() { root.refreshLinksTags() }
- function onContentChanged(i) {
- if (i === documentManager.activeIndex) root.refreshLinksTags()
- }
- }
- Connections {
- target: vaultIndex
- function onIndexChanged() { root.refreshLinksTags() }
+ function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); rebuildDebounce.restart() } }
+ function onDocumentSaved() { rebuildDebounce.restart() }
}
+ Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
+ Timer { id: rebuildDebounce; interval: 800; onTriggered: if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
- ColumnLayout {
+ // Делаем панели ресайзабельными внутри правой колонки
+ SplitView {
anchors.fill: parent
- spacing: 0
+ orientation: Qt.Vertical
+ handle: Rectangle { implicitHeight: 1; color: Theme.divider }
- // ---------- Календарь ----------
+ // Календарь
Rectangle {
- Layout.fillWidth: true
- implicitHeight: 218
- color: "#fafafa"
+ id: calBox
+ SplitView.preferredHeight: 240
+ SplitView.minimumHeight: 180
+ SplitView.maximumHeight: 360
+ color: Theme.bgAlt
+ border.color: Theme.divider
ColumnLayout {
anchors.fill: parent
spacing: 0
- Label {
- text: qsTr("Календарь")
- font.bold: true
- color: "#555"
- padding: 6
- }
- MonthCalendar {
+ Rectangle {
Layout.fillWidth: true
- Layout.fillHeight: true
- onDaySelected: (ds) => {
- const path = fileStore.dailyNotePath(ds)
- if (path.length > 0)
- documentManager.openDocument(path)
- }
- } }
- }
-
- // ---------- Ссылки и теги активного файла ----------
- Rectangle {
- Layout.fillWidth: true
- Layout.fillHeight: true
- color: "#f4f4f4"
- ColumnLayout {
- anchors.fill: parent
- anchors.margins: 6
- spacing: 4
-
- Label {
- text: qsTr("Ссылки и теги")
- font.bold: true
- color: "#555"
- }
- Label {
- text: root.activePath().length > 0
- ? qsTr("Файл: %1").arg(root.baseName(activePath()))
- : qsTr("Нет активного файла")
- elide: Text.ElideMiddle
- color: "#888"
- Layout.fillWidth: true
- font.pixelSize: 11
- }
-
- Label { text: qsTr("Ссылки"); color: "#666"; font.pixelSize: 11 }
- ListView {
- id: linksList
- Layout.fillWidth: true
- Layout.preferredHeight: Math.min(120, linksModel.count * 22)
- model: ListModel { id: linksModel }
- clip: true
- delegate: ItemDelegate {
- width: ListView.view.width
- height: 22
- text: model.text
- font.pixelSize: 12
- onClicked: documentManager.openDocument(model.path)
- }
- }
- Label {
- visible: linksModel.count === 0
- text: qsTr("— нет ссылок —")
- color: "#bbb"
- font.pixelSize: 11
- }
-
- Label { text: qsTr("Теги"); color: "#666"; font.pixelSize: 11 }
- Flow {
- Layout.fillWidth: true
- readonly property var m: ListModel { id: tagsModel }
- 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()
- }
+ 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 }
+ ToolButton {
+ text: "\u2637"
+ font.pixelSize: 12
+ ToolTip.text: qsTr("Сегодня")
+ ToolTip.visible: hovered
+ onClicked: cal.currentMonth = new Date()
}
}
}
- Item { Layout.fillHeight: 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 {
- Layout.fillWidth: true
- Layout.fillHeight: true
- color: "#eef1f5"
+ 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 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
- }
+ function baseName(p) { const idx = p.lastIndexOf("/"); return idx >= 0 ? p.substring(idx+1) : p }
Popup {
id: tagFilesPopup
modal: true
anchors.centerIn: parent
- width: 300
+ width: 360
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
ColumnLayout {
- width: parent.width
- Label { id: tagFilesTitle; font.bold: true }
- ListView {
- Layout.preferredHeight: Math.min(220, contentHeight)
+ 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
- model: ListModel { id: tagFilesModel }
clip: true
- delegate: ItemDelegate {
- width: ListView.view.width
- text: model.name
- onClicked: {
- documentManager.openDocument(model.path)
- tagFilesPopup.close()
+ 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() }
}
}
-}
\ No newline at end of file
+
+ 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 }
+}
diff --git a/qml/panels/TagChip.qml b/qml/panels/TagChip.qml
index f0fb20d..c8191e0 100644
--- a/qml/panels/TagChip.qml
+++ b/qml/panels/TagChip.qml
@@ -1,35 +1,39 @@
import QtQuick
import QtQuick.Controls
+import Flora
-// Кликабельный «чип» тега.
Item {
id: chip
property string text: ""
signal clicked()
- implicitWidth: label.implicitWidth + 16
- implicitHeight: 20
+ implicitWidth: label.implicitWidth + 18
+ implicitHeight: 22
Rectangle {
anchors.fill: parent
radius: height / 2
- color: mouse.containsMouse ? "#dbeafe" : "#e8eef7"
- border.color: "#b7c9e0"
+ color: mouse.containsMouse ? Theme.tagChipBgHover : Theme.tagChipBg
+ border.color: mouse.pressed ? Theme.accent : Theme.tagChipBorder
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
Label {
id: label
anchors.centerIn: parent
text: chip.text
- font.pixelSize: 10
- color: "#2b4b74"
+ font.pixelSize: 11
+ font.bold: true
+ color: Theme.tagChipText
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
onClicked: chip.clicked()
}
}
-}
\ No newline at end of file
+}
diff --git a/qml/panels/TbButton.qml b/qml/panels/TbButton.qml
index b572eea..c450622 100644
--- a/qml/panels/TbButton.qml
+++ b/qml/panels/TbButton.qml
@@ -1,7 +1,7 @@
import QtQuick
import QtQuick.Controls
+import Flora
-// Компактная кнопка тулбара.
Button {
id: b
@@ -10,14 +10,50 @@ Button {
property bool accent: false
signal tapped()
- implicitWidth: b.text.length > 1 ? 34 : 28
+ implicitWidth: b.text.length > 2 ? 36 : 30
implicitHeight: 26
font.bold: bold
- highlighted: accent
font.pixelSize: 13
+ palette.button: Theme.toolbarBg
+ palette.buttonText: Theme.text
+ palette.highlight: Theme.accent
- ToolTip.visible: hovered
+ // фон под тему
+ background: Rectangle {
+ color: b.pressed ? Theme.surfaceSelected : b.hovered ? Theme.surfaceHover : "transparent"
+ radius: 6
+ border.color: b.pressed ? Theme.accent : "transparent"
+ }
+ contentItem: Label {
+ text: b.text
+ font: b.font
+ color: b.accent ? Theme.accent : (b.enabled ? Theme.text : Theme.textFaint)
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ // особый стиль для цветных А
+ Rectangle {
+ visible: b.accent
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 6; height: 3
+ radius: 1
+ color: Theme.warningText
+ opacity: 0.9
+ }
+ Rectangle {
+ visible: b.bold && !b.accent
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 6; height: 3
+ radius: 1
+ color: Theme.accent
+ opacity: 0.9
+ }
+ }
+
+ ToolTip.visible: hovered && tip.length > 0
ToolTip.text: tip
+ ToolTip.delay: 600
onClicked: b.tapped()
-}
\ No newline at end of file
+}
diff --git a/qml/wizard/WelcomeWizard.qml b/qml/wizard/WelcomeWizard.qml
index 71ede82..809497c 100644
--- a/qml/wizard/WelcomeWizard.qml
+++ b/qml/wizard/WelcomeWizard.qml
@@ -2,11 +2,8 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Dialogs
+import Flora
-// Мастер первого запуска / добавления хранилища:
-// 1) выбор папки (новая или с файлами);
-// 2) автосоздание git-репозитория + подключение провайдеров (опционально);
-// 3) готово.
Dialog {
id: root
@@ -20,27 +17,48 @@ Dialog {
signal finished()
- title: firstRun ? qsTr("Первое знакомство: создайте хранилище")
- : qsTr("Новое хранилище")
+ title: firstRun ? qsTr("Первое знакомство: создайте хранилище") : qsTr("Новое хранилище")
modal: true
width: 560
height: 480
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
+
+ // индикатор шагов
+ header: Rectangle {
+ height: 48
+ color: Theme.bgAlt
+ radius: 10
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 8
+ Repeater {
+ model: 2
+ Rectangle {
+ Layout.fillWidth: true
+ height: 4
+ radius: 2
+ color: index <= root.step ? Theme.accent : Theme.border
+ }
+ }
+ }
+ Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider }
+ }
StackLayout {
id: stack
anchors.fill: parent
currentIndex: root.step
- // ---------- Шаг 1: выбор папки ----------
ColumnLayout {
spacing: 12
Label {
- text: qsTr("Выберите локальную папку — это будет ваше хранилище.\n"
- + "Можно указать новую папку (будет создана) или уже существующую с файлами.")
+ text: qsTr("Выберите локальную папку — это будет ваше хранилище.\nМожно указать новую папку (будет создана) или уже существующую с файлами.")
wrapMode: Text.Wrap
Layout.fillWidth: true
+ color: Theme.textSecondary
}
RowLayout {
Layout.fillWidth: true
@@ -48,67 +66,93 @@ Dialog {
id: pathField
Layout.fillWidth: true
placeholderText: qsTr("Путь к папке…")
+ placeholderTextColor: Theme.textFaint
text: root.vaultPath
+ color: Theme.text
+ background: Rectangle { color: Theme.bgAlt; border.color: pathField.activeFocus ? Theme.accent : Theme.border; radius: 6 }
onTextChanged: root.vaultPath = text
}
- Button {
- text: qsTr("Обзор…")
- onClicked: folderDialog.open()
- }
+ Button { text: qsTr("Обзор…"); onClicked: folderDialog.open() }
}
FolderDialog {
id: folderDialog
title: qsTr("Выберите папку хранилища")
onAccepted: {
- // selectedFolder — URL вида "file:///path/to/folder"
- var u = selectedFolder.toString()
- if (u.startsWith("file://"))
- root.vaultPath = u.substring(7)
- else
- root.vaultPath = u
+ // Используем toLocalFile где возможно для корректной обработки unicode и пробелов
+ let p = ""
+ try {
+ const url = selectedFolder
+ if (url && url.toString) {
+ // QUrl -> local file
+ // В QML FolderDialog selectedFolder уже QUrl, пробуем toString и декодируем
+ const s = url.toString()
+ if (s.startsWith("file://")) {
+ // Пытаемся через decodeURIComponent и обрезку file://
+ p = decodeURIComponent(s)
+ if (p.startsWith("file://")) p = p.substring(7)
+ // Windows: /C:/path -> C:/path
+ if (p.length >= 3 && p[0] === "/" && p[2] === ":" && /[A-Za-z]/.test(p[1])) p = p.substring(1)
+ // Убираем якорь/параметры если есть
+ const hash = p.indexOf('#')
+ if (hash >= 0) p = p.substring(0, hash)
+ const q = p.indexOf('?')
+ if (q >= 0) p = p.substring(0, q)
+ } else {
+ p = decodeURIComponent(s)
+ }
+ }
+ } catch(e) { console.warn("FolderDialog parse error", e) }
+ if (p.length === 0 && selectedFolder) {
+ try { p = selectedFolder.toString() } catch(e) {}
+ }
+ root.vaultPath = p
}
}
- Label {
- id: step1Error
- color: "red"
- wrapMode: Text.Wrap
+ Rectangle {
Layout.fillWidth: true
- text: ""
+ visible: step1Error.text.length > 0
+ color: Theme.errorBg
+ border.color: Theme.errorText
+ radius: 6
+ height: step1Error.implicitHeight + 12
+ Label {
+ id: step1Error
+ anchors.fill: parent
+ anchors.margins: 6
+ color: Theme.errorText
+ wrapMode: Text.Wrap
+ text: ""
+ }
}
Item { Layout.fillHeight: true }
+ Label { text: qsTr("Шаг 1 из 2 — выбор папки"); color: Theme.textFaint; font.pixelSize: 11 }
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
- Button {
- text: qsTr("Отмена")
- onClicked: root.reject()
- }
+ Button { text: qsTr("Отмена"); onClicked: root.reject() }
Button {
text: qsTr("Далее")
+ highlighted: true
enabled: root.vaultPath.length > 0 && !busyOverlay.running
onClicked: {
step1Error.text = ""
busyOverlay.running = true
const err = vaultManager.createVault(root.vaultPath)
busyOverlay.running = false
- if (err.length > 0) {
- step1Error.text = err
- return
- }
+ if (err.length > 0) { step1Error.text = err; return }
root.step = stepSetup
}
}
}
}
- // ---------- Шаг 2: git + провайдеры ----------
ColumnLayout {
spacing: 12
Label {
- text: qsTr("Хранилище готово. Внутри автоматически создан git-репозиторий.\n"
- + "При желании подключите синхронизацию:")
+ text: qsTr("Хранилище готово. Внутри автоматически создан git-репозиторий.\nПри желании подключите синхронизацию:")
wrapMode: Text.Wrap
Layout.fillWidth: true
+ color: Theme.textSecondary
}
GridLayout {
columns: 2
@@ -116,58 +160,36 @@ Dialog {
Layout.fillHeight: true
columnSpacing: 8
rowSpacing: 8
-
Button { text: "GitHub"; Layout.fillWidth: true; onClicked: openProviderGuide(0, "GitHub") }
Button { text: "GitLab"; Layout.fillWidth: true; onClicked: openProviderGuide(1, "GitLab") }
Button { text: "Gitea"; Layout.fillWidth: true; onClicked: openProviderGuide(2, "Gitea") }
Button { text: "Nextcloud (WebDAV)"; Layout.fillWidth: true; onClicked: openProviderGuide(3, "Nextcloud (WebDAV)") }
- Button { text: "Syncthing"; Layout.fillWidth: true; onClicked: openProviderGuide(4, "Syncthing") }
-
+ Button { text: "Syncthing"; Layout.fillWidth: true; Layout.columnSpan: 2; onClicked: openProviderGuide(4, "Syncthing") }
function openProviderGuide(idx, name) {
- console.log("openProviderGuide:", idx, name)
providerGuideForButtons.providerIndex = idx
providerGuideForButtons.providerName = name
providerGuideForButtons.connected = false
providerGuideForButtons.open()
- console.log("guide opened:", providerGuideForButtons.visible, providerGuideForButtons.width, providerGuideForButtons.height)
- }
- Label {
- color: "#888"
- Layout.columnSpan: 2
}
}
-
RowLayout {
Layout.fillWidth: true
+ Button { text: qsTr("Назад"); onClicked: root.step = stepFolder }
Item { Layout.fillWidth: true }
Button {
text: qsTr("Готово")
- onClicked: {
- vaultManager.selectVault(root.vaultPath)
- root.accept()
- root.finished()
- }
+ highlighted: true
+ onClicked: { vaultManager.selectVault(root.vaultPath); root.accept(); root.finished() }
}
}
}
}
- BusyIndicator {
- id: busyOverlay
- anchors.centerIn: parent
- running: false
- visible: running
- }
+ BusyIndicator { id: busyOverlay; anchors.centerIn: parent; running: false; visible: running }
- ProviderSetupGuide {
- id: providerGuideForButtons
- }
+ ProviderSetupGuide { id: providerGuideForButtons }
onRejected: {
- if (firstRun) {
- // Пусть пользователь продолжит позже; хранилище не выбрано.
- Qt.callLater(() => { console.log("Мастер отменён") })
- visible = false
- }
+ if (firstRun) visible = false
}
-}
\ No newline at end of file
+}
diff --git a/src/app/VaultManager.cpp b/src/app/VaultManager.cpp
index 57dfb81..24bee3e 100644
--- a/src/app/VaultManager.cpp
+++ b/src/app/VaultManager.cpp
@@ -4,6 +4,7 @@
#include "git/GitManager.h"
#include
+#include
VaultManager::VaultManager(SettingsStore *settings, GitManager *git, QObject *parent)
: QObject(parent)
@@ -26,37 +27,72 @@ QString VaultManager::currentVault() const
return m_settings->currentVault();
}
+static bool isValidVaultPath(const QString &clean, QString *err)
+{
+ if (clean.isEmpty()) {
+ if (err) *err = QStringLiteral("Не указан путь к хранилищу.");
+ return false;
+ }
+ // Запрещаем корень файловой системы и системные папки
+ if (clean == "/" || clean == "/tmp" || clean == "/etc" || clean == "/usr" || clean == "/var") {
+ if (err) *err = QStringLiteral("Недопустимый путь к хранилищу: %1").arg(clean);
+ return false;
+ }
+ QFileInfo fi(clean);
+ if (fi.exists() && !fi.isDir()) {
+ if (err) *err = QStringLiteral("Путь «%1» существует и не является папкой.").arg(clean);
+ return false;
+ }
+ // Проверяем что путь не внутри другого хранилища (вложенность)
+ // и родитель существует или может быть создан
+ return true;
+}
+
QString VaultManager::createVault(const QString &path)
{
- if (path.trimmed().isEmpty())
- return QStringLiteral("Не указан путь к хранилищу.");
+ const QString clean = QDir::cleanPath(path.trimmed());
+ QString err;
+ if (!isValidVaultPath(clean, &err))
+ return err;
- if (!QDir().mkpath(path))
- return QStringLiteral("Не удалось создать папку «%1».").arg(path);
+ if (!QDir().mkpath(clean))
+ return QStringLiteral("Не удалось создать папку «%1». Проверьте права доступа.").arg(clean);
- if (!m_git->initRepo(path)) {
+ // Проверяем что папка действительно создана и доступна для записи
+ QFileInfo fi(clean);
+ if (!fi.isDir() || !fi.isWritable())
+ return QStringLiteral("Нет прав записи в папку «%1».").arg(clean);
+
+ if (!m_git->initRepo(clean)) {
emit operationError(m_git->lastError());
return m_git->lastError();
}
- m_settings->addVault(path);
+ m_settings->addVault(clean);
return QString();
}
QString VaultManager::openVault(const QString &path)
{
- if (path.trimmed().isEmpty())
- return QStringLiteral("Не указан путь к хранилищу.");
+ const QString clean = QDir::cleanPath(path.trimmed());
+ QString err;
+ if (!isValidVaultPath(clean, &err))
+ return err;
- if (!QDir(path).exists())
- return QStringLiteral("Папка «%1» не существует.").arg(path);
+ QFileInfo fi(clean);
+ if (!fi.exists())
+ return QStringLiteral("Папка «%1» не существует.").arg(clean);
+ if (!fi.isDir())
+ return QStringLiteral("Путь «%1» не является папкой.").arg(clean);
+ if (!fi.isReadable())
+ return QStringLiteral("Нет прав чтения папки «%1».").arg(clean);
- if (!m_git->initRepo(path)) {
+ if (!m_git->initRepo(clean)) {
emit operationError(m_git->lastError());
return m_git->lastError();
}
- m_settings->addVault(path);
+ m_settings->addVault(clean);
return QString();
}
diff --git a/src/app/WindowController.cpp b/src/app/WindowController.cpp
index e524a9a..ccb62fc 100644
--- a/src/app/WindowController.cpp
+++ b/src/app/WindowController.cpp
@@ -1,10 +1,14 @@
#include "app/WindowController.h"
#include
+#include
#include
#include
+#include
+#include "core/DocumentManager.h"
#include "core/FileStore.h"
+#include "core/VaultIndex.h"
WindowController::WindowController(QQmlApplicationEngine *engine,
const QString &mainWindowUrl,
@@ -31,8 +35,9 @@ QStringList WindowController::openVaults() const
int WindowController::indexOfVault(const QString &vaultPath) const
{
+ const QString clean = QDir::cleanPath(vaultPath);
for (int i = 0; i < m_windows.size(); ++i)
- if (m_windows[i].vault == vaultPath)
+ if (QDir::cleanPath(m_windows[i].vault) == clean)
return i;
return -1;
}
@@ -49,37 +54,81 @@ QObject *WindowController::openWindow(const QString &vaultPath)
return nullptr;
}
}
+ if (m_component->status() != QQmlComponent::Ready) {
+ if (m_component->isError()) {
+ qWarning("WindowController: component not ready: %s", qPrintable(m_component->errorString()));
+ return nullptr;
+ }
+ // Если компонент ещё загружается — ждём синхронно (локальный файл, быстро)
+ qWarning("WindowController: component status %d, waiting", int(m_component->status()));
+ if (m_component->status() == QQmlComponent::Loading) {
+ QEventLoop loop;
+ connect(m_component, &QQmlComponent::statusChanged, &loop, &QEventLoop::quit);
+ loop.exec();
+ if (m_component->status() != QQmlComponent::Ready) {
+ qWarning("WindowController: after wait status %d error %s", int(m_component->status()), qPrintable(m_component->errorString()));
+ return nullptr;
+ }
+ }
+ }
QQmlContext *ctx = new QQmlContext(m_engine->rootContext(), this);
FileStore *store = new FileStore(ctx);
+ DocumentManager *docMgr = new DocumentManager(ctx);
+ VaultIndex *vIndex = new VaultIndex(ctx);
ctx->setContextProperty("fileStore", store);
+ ctx->setContextProperty("documentManager", docMgr);
+ ctx->setContextProperty("vaultIndex", vIndex);
QObject *obj = m_component->create(ctx);
if (!obj) {
qWarning("WindowController: create failed: %s",
qPrintable(m_component->errorString()));
+ ctx->deleteLater();
return nullptr;
}
- m_windows.append({vaultPath, obj, store});
- connect(obj, &QObject::destroyed, this, [this, obj]() {
+ // Проверяем что создание дало QQuickWindow
+ if (auto *win = qobject_cast(obj)) {
+ // Связываем закрытие окна с удалением объекта
+ connect(win, &QQuickWindow::closing, this, [obj](auto *) {
+ // Отложенное удаление чтобы не нарушать цикл событий
+ obj->deleteLater();
+ });
+ }
+
+ const QString cleanVault = vaultPath.isEmpty() ? QString() : QDir::cleanPath(vaultPath);
+ m_windows.append({cleanVault, obj, store, docMgr, vIndex, ctx});
+ connect(obj, &QObject::destroyed, this, [this, obj, ctx]() {
for (int i = 0; i < m_windows.size(); ++i) {
if (m_windows[i].object == obj) {
const QString vault = m_windows[i].vault;
+ QQmlContext *c = m_windows[i].context;
m_windows.removeAt(i);
+ if (c)
+ c->deleteLater();
emit windowsChanged();
if (!vault.isEmpty())
emit windowClosed(vault);
break;
}
}
+ // fallback: если окно уже удалено из списка, всё равно чистим контекст
+ if (ctx && ctx->parent() == this) {
+ bool still = false;
+ for (const auto &w : std::as_const(m_windows))
+ if (w.context == ctx) { still = true; break; }
+ if (!still) ctx->deleteLater();
+ }
});
- obj->setProperty("windowVault", vaultPath);
+ obj->setProperty("windowVault", cleanVault);
// Закрытие окна уничтожает QML-объект (чтобы последнее окно завершало приложение).
- obj->setProperty("destroyOnWindowClose", true);
- if (!vaultPath.isEmpty() && QDir(vaultPath).exists())
- store->openVault(vaultPath);
+ // Устанавливаем через динамическое свойство если поддерживается
+ if (obj->property("destroyOnWindowClose").isValid())
+ obj->setProperty("destroyOnWindowClose", true);
+ if (!cleanVault.isEmpty() && QDir(cleanVault).exists())
+ store->openVault(cleanVault);
emit windowsChanged();
if (!vaultPath.isEmpty())
diff --git a/src/app/WindowController.h b/src/app/WindowController.h
index 154ad39..10e5f9d 100644
--- a/src/app/WindowController.h
+++ b/src/app/WindowController.h
@@ -6,6 +6,8 @@
#include
class FileStore;
+class DocumentManager;
+class VaultIndex;
// Создание окон приложения (мульти-хранилища: новое хранилище — новое окно).
class WindowController : public QObject
@@ -37,6 +39,9 @@ private:
QString vault;
QPointer object;
FileStore *fileStore = nullptr;
+ DocumentManager *docManager = nullptr;
+ VaultIndex *vaultIndex = nullptr;
+ QQmlContext *context = nullptr;
};
QQmlApplicationEngine *m_engine;
diff --git a/src/core/DocumentManager.cpp b/src/core/DocumentManager.cpp
index 7ede2e5..5f08d35 100644
--- a/src/core/DocumentManager.cpp
+++ b/src/core/DocumentManager.cpp
@@ -1,22 +1,25 @@
#include "core/DocumentManager.h"
#include
+#include
#include
#include
#include
#include
+#include
DocumentManager::DocumentManager(QObject *parent)
: QObject(parent)
{
- QTimer *autosave = new QTimer(this);
- autosave->setInterval(m_autosaveMs);
- autosave->setSingleShot(false);
- connect(autosave, &QTimer::timeout, this, &DocumentManager::saveAll);
- autosave->start();
+ m_autosave = new QTimer(this);
+ m_autosave->setInterval(m_autosaveMs);
+ m_autosave->setSingleShot(false);
+ connect(m_autosave, &QTimer::timeout, this, &DocumentManager::saveAll);
+ m_autosave->start();
// Защита при выходе
- connect(qApp, &QCoreApplication::aboutToQuit, this, &DocumentManager::saveAll);
+ if (qApp)
+ connect(qApp, &QCoreApplication::aboutToQuit, this, &DocumentManager::saveAll);
}
QStringList DocumentManager::openTabs() const
@@ -31,9 +34,13 @@ QStringList DocumentManager::openTabs() const
int DocumentManager::activeIndex() const { return m_active; }
void DocumentManager::setActiveIndex(int index)
{
- qDebug("setActiveIndex: from %d to %d", m_active, index);
if (m_active == index)
return;
+ if (index < -1 || index >= m_docs.size()) {
+ // allow -1 for no active, but clamp
+ if (index != -1 && (index < 0 || index >= m_docs.size()))
+ return;
+ }
m_active = index;
emit activeIndexChanged();
}
@@ -59,9 +66,14 @@ void DocumentManager::setSideBySide(bool on)
int DocumentManager::autosaveIntervalMs() const { return m_autosaveMs; }
void DocumentManager::setAutosaveIntervalMs(int ms)
{
+ // Ограничиваем интервал 500мс .. 120с, защита от спама/заморозки
+ if (ms < 500) ms = 500;
+ if (ms > 120000) ms = 120000;
if (m_autosaveMs == ms)
return;
m_autosaveMs = ms;
+ if (m_autosave)
+ m_autosave->setInterval(ms);
emit autosaveIntervalChanged();
}
@@ -69,7 +81,23 @@ QString DocumentManager::lastStatus() const { return m_lastStatus; }
int DocumentManager::openDocument(const QString &path)
{
- const QString clean = QFileInfo(path).absoluteFilePath();
+ if (path.trimmed().isEmpty()) {
+ m_lastStatus = QStringLiteral("Путь пустой");
+ emit lastStatusChanged();
+ return -1;
+ }
+ const QString clean = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
+ QFileInfo fi(clean);
+ if (fi.isDir()) {
+ m_lastStatus = QStringLiteral("Это папка, а не файл: %1").arg(clean);
+ emit lastStatusChanged();
+ return -1;
+ }
+ if (fi.exists() && fi.size() > 10 * 1024 * 1024) {
+ m_lastStatus = QStringLiteral("Файл слишком большой (>10 МБ): %1").arg(clean);
+ emit lastStatusChanged();
+ return -1;
+ }
for (int i = 0; i < m_docs.size(); ++i) {
if (m_docs[i].path == clean) {
setActiveIndex(i);
@@ -92,8 +120,6 @@ int DocumentManager::openDocument(const QString &path)
m_docs.append(doc);
const int idx = m_docs.size() - 1;
- qDebug("openDocument: path=%s idx=%d content_len=%d",
- qPrintable(clean), idx, content.length());
setActiveIndex(idx);
emit openTabsChanged();
return idx;
@@ -101,13 +127,27 @@ int DocumentManager::openDocument(const QString &path)
QString DocumentManager::readFile(const QString &path, bool *ok) const
{
+ QFileInfo fi(path);
+ if (fi.exists() && fi.size() > 10 * 1024 * 1024) {
+ if (ok) *ok = false;
+ return QString();
+ }
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) {
if (ok) *ok = false;
return QString();
}
- const QByteArray bytes = f.readAll();
+ // Ограничиваем чтение 10 МБ для защиты от OOM
+ const QByteArray bytes = f.read(10 * 1024 * 1024 + 1);
+ if (bytes.size() > 10 * 1024 * 1024) {
+ if (ok) *ok = false;
+ return QString();
+ }
if (ok) *ok = true;
+ // Пытаемся детектировать бинарный файл по нулевым байтам
+ if (bytes.contains('\0')) {
+ // Считаем бинарным, но всё равно возвращаем как текст для просмотра с предупреждением
+ }
return QString::fromUtf8(bytes);
}
@@ -194,7 +234,14 @@ bool DocumentManager::closeTab(int index)
if (index < 0 || index >= m_docs.size())
return false;
// При закрытии тоже сохраняем (защита от потери текста).
+ // Если сохранение не удалось и файл всё ещё dirty — не закрываем, чтобы не потерять данные
+ bool wasDirty = isDirty(index);
saveDocument(index);
+ if (wasDirty && isDirty(index)) {
+ m_lastStatus = QStringLiteral("Не удалось сохранить, вкладка не закрыта: %1").arg(m_docs[index].path);
+ emit lastStatusChanged();
+ return false;
+ }
m_docs.removeAt(index);
@@ -212,4 +259,29 @@ bool DocumentManager::closeTab(int index)
emit activeIndexChanged();
emit secondaryIndexChanged();
return true;
+}
+
+void DocumentManager::moveTab(int from, int to)
+{
+ if (from < 0 || from >= m_docs.size() || to < 0 || to >= m_docs.size() || from == to)
+ return;
+ m_docs.move(from, to);
+ // корректируем активные индексы
+ if (m_active == from) {
+ m_active = to;
+ } else if (from < m_active && m_active <= to) {
+ --m_active;
+ } else if (to <= m_active && m_active < from) {
+ ++m_active;
+ }
+ if (m_secondary == from) {
+ m_secondary = to;
+ } else if (from < m_secondary && m_secondary <= to) {
+ --m_secondary;
+ } else if (to <= m_secondary && m_secondary < from) {
+ ++m_secondary;
+ }
+ emit openTabsChanged();
+ emit activeIndexChanged();
+ emit secondaryIndexChanged();
}
\ No newline at end of file
diff --git a/src/core/DocumentManager.h b/src/core/DocumentManager.h
index 5c17df1..f8163d6 100644
--- a/src/core/DocumentManager.h
+++ b/src/core/DocumentManager.h
@@ -24,13 +24,13 @@ public:
QStringList openTabs() const;
int activeIndex() const;
- void setActiveIndex(int index);
+ Q_INVOKABLE void setActiveIndex(int index);
int secondaryIndex() const;
- void setSecondaryIndex(int index);
+ Q_INVOKABLE void setSecondaryIndex(int index);
bool sideBySide() const;
- void setSideBySide(bool on);
+ Q_INVOKABLE void setSideBySide(bool on);
int autosaveIntervalMs() const;
- void setAutosaveIntervalMs(int ms);
+ Q_INVOKABLE void setAutosaveIntervalMs(int ms);
QString lastStatus() const;
// Открывает документ (при необходимости создаётся вкладка). Возвращает индекс.
@@ -52,6 +52,7 @@ public:
Q_INVOKABLE void saveAll();
Q_INVOKABLE bool closeTab(int index);
+ Q_INVOKABLE void moveTab(int from, int to);
signals:
void openTabsChanged();
@@ -84,4 +85,5 @@ private:
bool m_sideBySide = false;
int m_autosaveMs = 5000;
QString m_lastStatus;
+ QTimer *m_autosave = nullptr;
};
\ No newline at end of file
diff --git a/src/core/FileStore.cpp b/src/core/FileStore.cpp
index 58788da..30bc8e1 100644
--- a/src/core/FileStore.cpp
+++ b/src/core/FileStore.cpp
@@ -3,6 +3,9 @@
#include
#include
#include
+#include
+#include
+#include
FileStore::FileStore(QObject *parent)
: QAbstractListModel(parent)
@@ -83,9 +86,14 @@ QString FileStore::iconFor(const Row &r) const
QString FileStore::openVault(const QString &path)
{
- const QString clean = QDir::cleanPath(path);
- if (!QDir(clean).exists())
+ const QString clean = QDir::cleanPath(path.trimmed());
+ if (clean.isEmpty())
+ return QStringLiteral("Путь к хранилищу не указан.");
+ QFileInfo fi(clean);
+ if (!fi.exists())
return QStringLiteral("Папка «%1» не существует.").arg(clean);
+ if (!fi.isDir())
+ return QStringLiteral("Путь «%1» не является папкой.").arg(clean);
m_vault = clean;
m_expanded.clear();
@@ -99,8 +107,6 @@ QString FileStore::openVault(const QString &path)
void FileStore::rebuild()
{
- if (!m_watcher.directories().isEmpty())
- m_watcher.removePaths(m_watcher.directories());
QVector rows;
collectDir(m_vault, 0, rows);
@@ -108,11 +114,7 @@ void FileStore::rebuild()
m_rows = std::move(rows);
endResetModel();
- // Регистрируем все каталоги для слежения
- for (const Row &r : m_rows) {
- if (r.isDir && !m_watcher.directories().contains(r.path))
- m_watcher.addPath(r.path);
- }
+ registerWatcher();
}
void FileStore::collectDir(const QString &dir, int depth, QVector &out) const
@@ -133,11 +135,14 @@ void FileStore::collectDir(const QString &dir, int depth, QVector &out) con
row.depth = depth;
row.isDir = fi.isDir();
if (row.isDir) {
- const int childCount = QDir(path)
- .entryList(QDir::AllEntries | QDir::NoDotAndDotDot
- | QDir::System)
- .count();
- row.hasChildren = childCount > 0;
+ QDir cd(path);
+ cd.setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::System | QDir::NoSymLinks);
+ const QFileInfoList children = cd.entryInfoList();
+ int visible = 0;
+ for (const auto &c : children)
+ if (!isIgnored(c.absoluteFilePath()))
+ ++visible;
+ row.hasChildren = visible > 0;
}
row.expanded = row.isDir && m_expanded.contains(path);
(row.isDir ? dirs : files).append(row);
@@ -163,7 +168,8 @@ bool FileStore::isIgnored(const QString &path) const
{
const QString name = QFileInfo(path).fileName();
return name == QLatin1String(".git") || name == QLatin1String(".flora")
- || name == QLatin1String(".stfolder");
+ || name == QLatin1String(".stfolder") || name == QLatin1String(".DS_Store")
+ || name == QLatin1String("Thumbs.db");
}
void FileStore::registerWatcher()
@@ -171,14 +177,44 @@ void FileStore::registerWatcher()
const auto dirs = m_watcher.directories();
for (const QString &d : dirs)
m_watcher.removePath(d);
+ if (m_vault.isEmpty() || !QDir(m_vault).exists())
+ return;
+ // Следим за всеми каталогами хранилища, даже свёрнутыми, чтобы внешние изменения
+ // внутри свёрнутых папок приводили к rebuild (иначе watcher пропустит события).
QVector dirsToWatch;
+ dirsToWatch.append(m_vault);
+ // Собираем рекурсивно все подкаталоги, игнорируя служебные
+ std::function collect = [&](const QString &cur) {
+ QDir d(cur);
+ d.setFilter(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks | QDir::System);
+ const QFileInfoList subs = d.entryInfoList();
+ for (const QFileInfo &fi : subs) {
+ if (isIgnored(fi.absoluteFilePath()))
+ continue;
+ const QString clean = QDir::cleanPath(fi.absoluteFilePath());
+ if (!dirsToWatch.contains(clean))
+ dirsToWatch.append(clean);
+ collect(fi.absoluteFilePath());
+ }
+ };
+ collect(m_vault);
+ // Также добавляем видимые dirs на случай рассинхрона
for (const Row &r : m_rows)
- if (r.isDir)
+ if (r.isDir && !dirsToWatch.contains(r.path))
dirsToWatch.append(r.path);
- if (!dirsToWatch.isEmpty())
- m_watcher.addPaths(dirsToWatch);
- if (!m_vault.isEmpty() && QDir(m_vault).exists())
- m_watcher.addPath(m_vault);
+ // Ограничение QFileSystemWatcher: лимит дескрипторов (обычно 256-512)
+ // При превышении addPaths вернёт пустой список без ошибки - проверяем
+ if (dirsToWatch.size() > 512) {
+ // Оставляем только верхний уровень + видимые для экономии дескрипторов
+ QVector limited;
+ limited.append(m_vault);
+ for (const Row &r : m_rows)
+ if (r.isDir && !limited.contains(r.path))
+ limited.append(r.path);
+ dirsToWatch = limited;
+ }
+ const QStringList failed = m_watcher.addPaths(dirsToWatch);
+ Q_UNUSED(failed)
}
void FileStore::scheduleRebuild()
@@ -226,13 +262,42 @@ QString FileStore::validateName(const QString &name) const
const QString n = name.trimmed();
if (n.isEmpty())
return QStringLiteral("Имя не может быть пустым.");
- if (n.contains('/') || n.contains('\\') || n == "." || n == "..")
- return QStringLiteral("Имя содержит недопустимые символы (/, \\).");
+ if (n == "." || n == "..")
+ return QStringLiteral("Имя не может быть «.» или «..».");
+ if (n.startsWith('.'))
+ return QStringLiteral("Имя не может начинаться с точки.");
+ // Запрещённые символы (кроссплатформенно): / \ : * ? " < > | и управляющие 0x00-0x1F
+ static const QRegularExpression invalidRe(QStringLiteral("[\\\\/:*?\"<>|\\x00-\\x1F]"));
+ if (n.contains('/') || n.contains('\\') || invalidRe.match(n).hasMatch())
+ return QStringLiteral("Имя содержит недопустимые символы (/, \\, :, *, ?, \", <, >, |).");
+ // Зарезервированные имена Windows (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
+ static const QSet reserved = {
+ "CON","PRN","AUX","NUL",
+ "COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8","COM9",
+ "LPT1","LPT2","LPT3","LPT4","LPT5","LPT6","LPT7","LPT8","LPT9"
+ };
+ const QString upper = n.split('.').first().toUpper();
+ if (reserved.contains(upper))
+ return QStringLiteral("Имя зарезервировано системой.");
if (n.length() > 200)
return QStringLiteral("Имя слишком длинное.");
+ if (n.endsWith(' ') || n.endsWith('.'))
+ return QStringLiteral("Имя не может заканчиваться пробелом или точкой.");
return QString();
}
+bool FileStore::isInsideVault(const QString &path) const
+{
+ if (m_vault.isEmpty())
+ return false;
+ const QString vault = QDir::cleanPath(m_vault);
+ const QString p = QDir::cleanPath(path);
+ if (p == vault)
+ return true;
+ // Должен начинаться с vault + '/'
+ return p.startsWith(vault + '/');
+}
+
QString FileStore::parentDirOfRow(int row) const
{
if (row <= 0)
@@ -246,7 +311,14 @@ QString FileStore::parentDirOfRow(int row) const
QString FileStore::createFile(const QString &parentPath, const QString &name)
{
- const QString dir = parentPath.isEmpty() ? m_vault : parentPath;
+ if (m_vault.isEmpty())
+ return QStringLiteral("Хранилище не открыто.");
+ QString dir = parentPath.isEmpty() ? m_vault : QDir::cleanPath(parentPath);
+ if (!isInsideVault(dir))
+ return QStringLiteral("Путь вне хранилища: %1").arg(dir);
+ QFileInfo dirInfo(dir);
+ if (!dirInfo.exists() || !dirInfo.isDir())
+ return QStringLiteral("Папка не существует: %1").arg(dir);
const QString trimmed = name.trimmed();
if (trimmed.isEmpty())
return QStringLiteral("Имя файла не может быть пустым.");
@@ -258,6 +330,9 @@ QString FileStore::createFile(const QString &parentPath, const QString &name)
if (!err.isEmpty())
return err;
const QString path = QDir(dir).filePath(actualName);
+ const QString cleanPath = QDir::cleanPath(path);
+ if (!isInsideVault(cleanPath))
+ return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(path))
return QStringLiteral("Файл «%1» уже существует в этом каталоге.").arg(actualName);
QFile f(path);
@@ -272,9 +347,19 @@ QString FileStore::dailyNotePath(const QString &dateStr)
{
if (m_vault.isEmpty())
return QString();
- if (!QDir().mkpath(m_vault + "/Журнал"))
+ // Валидация формата даты: YYYY-MM-DD
+ static const QRegularExpression dateRe(QStringLiteral("^\\d{4}-\\d{2}-\\d{2}$"));
+ if (!dateRe.match(dateStr).hasMatch())
+ return QString();
+ const QString journalDir = QDir::cleanPath(m_vault + "/Журнал");
+ if (!isInsideVault(journalDir))
+ return QString();
+ if (!QDir().mkpath(journalDir))
+ return QString();
+ const QString path = QDir(journalDir).filePath(dateStr + ".md");
+ const QString cleanPath = QDir::cleanPath(path);
+ if (!isInsideVault(cleanPath))
return QString();
- const QString path = m_vault + "/Журнал/" + dateStr + ".md";
if (!QFileInfo::exists(path)) {
QFile f(path);
if (f.open(QIODevice::WriteOnly)) {
@@ -283,47 +368,76 @@ QString FileStore::dailyNotePath(const QString &dateStr)
} else {
return QString();
}
- m_expanded.insert(m_vault + "/Журнал");
- scheduleRebuild();
+ m_expanded.insert(journalDir);
+ rebuild();
}
- return path;
+ return cleanPath;
}
QString FileStore::createFolder(const QString &parentPath, const QString &name)
{
- const QString dir = parentPath.isEmpty() ? m_vault : parentPath;
- const QString err = validateName(name);
+ if (m_vault.isEmpty())
+ return QStringLiteral("Хранилище не открыто.");
+ QString dir = parentPath.isEmpty() ? m_vault : QDir::cleanPath(parentPath);
+ if (!isInsideVault(dir))
+ return QStringLiteral("Путь вне хранилища: %1").arg(dir);
+ QFileInfo dirInfo(dir);
+ if (!dirInfo.exists() || !dirInfo.isDir())
+ return QStringLiteral("Папка не существует: %1").arg(dir);
+ const QString err = validateName(name.trimmed());
if (!err.isEmpty())
return err;
- const QString path = QDir(dir).filePath(name);
+ const QString path = QDir(dir).filePath(name.trimmed());
+ const QString cleanPath = QDir::cleanPath(path);
+ if (!isInsideVault(cleanPath))
+ return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(path))
return QStringLiteral("Папка с таким именем уже существует.");
if (!QDir().mkpath(path))
return QStringLiteral("Не удалось создать папку: %1").arg(path);
- m_expanded.insert(path);
+ m_expanded.insert(QDir::cleanPath(path));
rebuild();
return QString();
}
QString FileStore::renameItem(const QString &path, const QString &newName)
{
+ if (m_vault.isEmpty())
+ return QStringLiteral("Хранилище не открыто.");
+ const QString cleanPath = QDir::cleanPath(path);
+ if (!isInsideVault(cleanPath))
+ return QStringLiteral("Путь вне хранилища: %1").arg(path);
+ QFileInfo fi(cleanPath);
+ if (!fi.exists())
+ return QStringLiteral("Элемент не существует: %1").arg(path);
const QString n = newName.trimmed();
const QString err = validateName(n);
if (!err.isEmpty())
return err;
- QFileInfo fi(path);
const QString newPath = QDir(fi.absolutePath()).filePath(n);
- if (path == newPath)
+ const QString cleanNew = QDir::cleanPath(newPath);
+ if (!isInsideVault(cleanNew))
+ return QStringLiteral("Новый путь вне хранилища.");
+ if (cleanPath == cleanNew)
return QString();
if (QFileInfo::exists(newPath))
return QStringLiteral("Имя уже занято.");
- if (!QFile::rename(path, newPath) && !QDir().rename(path, newPath))
+ if (!QFile::rename(cleanPath, newPath) && !QDir().rename(cleanPath, newPath))
return QStringLiteral("Не удалось переименовать: %1").arg(path);
if (fi.isDir()) {
- const QString oldAbs = QDir::cleanPath(path);
- const QString newAbs = QDir::cleanPath(newPath);
- if (m_expanded.remove(oldAbs))
- m_expanded.insert(newAbs);
+ const QString oldAbs = cleanPath;
+ const QString newAbs = cleanNew;
+ // Обновляем все раскрытые пути с префиксом oldAbs (включая вложенные)
+ QSet updated;
+ for (const QString &p : std::as_const(m_expanded)) {
+ if (p == oldAbs)
+ updated.insert(newAbs);
+ else if (p.startsWith(oldAbs + '/'))
+ updated.insert(newAbs + p.mid(oldAbs.size()));
+ else
+ updated.insert(p);
+ }
+ m_expanded = std::move(updated);
}
rebuild();
return QString();
@@ -331,14 +445,28 @@ QString FileStore::renameItem(const QString &path, const QString &newName)
QString FileStore::removeItem(const QString &path)
{
- QFileInfo fi(path);
+ if (m_vault.isEmpty())
+ return QStringLiteral("Хранилище не открыто.");
+ const QString clean = QDir::cleanPath(path);
+ if (!isInsideVault(clean))
+ return QStringLiteral("Путь вне хранилища: %1").arg(path);
+ if (clean == QDir::cleanPath(m_vault))
+ return QStringLiteral("Нельзя удалить корень хранилища.");
+ QFileInfo fi(clean);
+ if (!fi.exists())
+ return QStringLiteral("Элемент не существует: %1").arg(path);
if (fi.isDir()) {
- QDir d(path);
+ QDir d(clean);
if (!d.removeRecursively())
return QStringLiteral("Не удалось удалить папку «%1».").arg(path);
- m_expanded.remove(QDir::cleanPath(path));
+ QSet updated;
+ for (const QString &p : std::as_const(m_expanded)) {
+ if (p != clean && !p.startsWith(clean + '/'))
+ updated.insert(p);
+ }
+ m_expanded = std::move(updated);
} else {
- if (!QFile::remove(path))
+ if (!QFile::remove(clean))
return QStringLiteral("Не удалось удалить файл «%1».").arg(path);
}
rebuild();
@@ -347,19 +475,34 @@ QString FileStore::removeItem(const QString &path)
QString FileStore::moveItem(const QString &srcPath, const QString &destDir)
{
+ if (m_vault.isEmpty())
+ return QStringLiteral("Хранилище не открыто.");
if (srcPath.isEmpty() || destDir.isEmpty())
return QStringLiteral("Не указаны пути.");
const QString src = QDir::cleanPath(srcPath);
const QString dstDir = QDir::cleanPath(destDir);
- if (src == dstDir || QFileInfo(dstDir).filePath() == QFileInfo(src).absolutePath())
+ if (!isInsideVault(src) || !isInsideVault(dstDir))
+ return QStringLiteral("Путь вне хранилища.");
+ QFileInfo srcInfo(src);
+ QFileInfo dstInfo(dstDir);
+ if (!srcInfo.exists())
+ return QStringLiteral("Источник не существует: %1").arg(src);
+ if (!dstInfo.exists() || !dstInfo.isDir())
+ return QStringLiteral("Папка назначения не существует или не является папкой: %1").arg(dstDir);
+ if (src == QDir::cleanPath(m_vault))
+ return QStringLiteral("Нельзя переместить корень хранилища.");
+ if (src == dstDir || dstDir == QDir::cleanPath(QFileInfo(src).absolutePath()))
return QString();
- // Запрещаем перемещение папки в саму себя
- if (src.startsWith(dstDir + '/'))
+ // Запрещаем перемещение папки в саму себя или в подпапку
+ if (dstDir == src || dstDir.startsWith(src + '/'))
return QStringLiteral("Нельзя переместить папку в саму себя.");
const QFileInfo fi(src);
const QString newPath = QDir(dstDir).filePath(fi.fileName());
+ const QString cleanNew = QDir::cleanPath(newPath);
+ if (!isInsideVault(cleanNew))
+ return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(newPath))
return QStringLiteral("Элемент с таким именем уже есть в папке назначения.");
@@ -371,6 +514,21 @@ QString FileStore::moveItem(const QString &srcPath, const QString &destDir)
if (!ok)
return QStringLiteral("Не удалось переместить «%1».").arg(src);
+ // переносим состояние раскрытия для перемещаемой папки и её подпапок
+ if (fi.isDir()) {
+ const QString oldAbs = src;
+ const QString newAbs = cleanNew;
+ QSet updated;
+ for (const QString &p : std::as_const(m_expanded)) {
+ if (p == oldAbs)
+ updated.insert(newAbs);
+ else if (p.startsWith(oldAbs + '/'))
+ updated.insert(newAbs + p.mid(oldAbs.size()));
+ else
+ updated.insert(p);
+ }
+ m_expanded = std::move(updated);
+ }
rebuild();
return QString();
}
diff --git a/src/core/FileStore.h b/src/core/FileStore.h
index b6b07ff..1bf5cc3 100644
--- a/src/core/FileStore.h
+++ b/src/core/FileStore.h
@@ -84,7 +84,8 @@ private:
void registerWatcher();
void scheduleRebuild();
-QString validateName(const QString &name) const;
+ QString validateName(const QString &name) const;
+ bool isInsideVault(const QString &path) const;
QString parentDirOfRow(int row) const;
QString iconFor(const Row &row) const;
diff --git a/src/core/SettingsStore.cpp b/src/core/SettingsStore.cpp
index 87b5910..07a40ca 100644
--- a/src/core/SettingsStore.cpp
+++ b/src/core/SettingsStore.cpp
@@ -1,12 +1,31 @@
#include "core/SettingsStore.h"
+#include
#include
+#include
+#include
namespace {
constexpr auto kGroupVaults = "vaults";
constexpr auto kKeyVaults = "vaults/vaultList";
constexpr auto kKeyCurrent = "vaults/current";
constexpr auto kKeyProviders = "sync/providers";
+constexpr auto kKeyTheme = "ui/theme";
+constexpr auto kKeyLeftW = "ui/leftPanelWidth";
+constexpr auto kKeyRightW = "ui/rightPanelWidth";
+constexpr auto kKeyViewModes = "ui/viewModes";
+
+QString encodeKey(const QString &s) {
+ // Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini
+ return QString::fromLatin1(QUrl::toPercentEncoding(s, QByteArray(), QByteArray("/")));
+}
+QString encodeRel(const QString &rel) {
+ // Для относительных путей тоже кодируем "/" как %2F чтобы ключ остался плоским
+ return QString::fromLatin1(QUrl::toPercentEncoding(rel));
+}
+QString decodeKey(const QString &s) {
+ return QUrl::fromPercentEncoding(s.toLatin1());
+}
}
SettingsStore::SettingsStore(QObject *parent)
@@ -31,7 +50,8 @@ QString SettingsStore::currentVault() const
void SettingsStore::setCurrentVault(const QString &path)
{
QSettings s;
- s.setValue(kKeyCurrent, path);
+ s.setValue(kKeyCurrent, QDir::cleanPath(path));
+ s.sync();
emit currentVaultChanged();
}
@@ -42,18 +62,26 @@ QVariantList SettingsStore::providers() const
void SettingsStore::addVault(const QString &path)
{
+ const QString clean = QDir::cleanPath(path);
+ if (clean.isEmpty())
+ return;
QStringList list = vaults();
- if (!list.contains(path))
- list.prepend(path);
+ // нормализуем существующие записи
+ for (QString &v : list) v = QDir::cleanPath(v);
+ list.removeAll(clean);
+ list.prepend(clean);
QSettings s;
s.setValue(kKeyVaults, list);
- s.setValue(kKeyCurrent, path);
+ s.setValue(kKeyCurrent, clean);
+ s.sync();
emit vaultsChanged();
emit currentVaultChanged();
}
void SettingsStore::addProvider(const QString &provider, const QVariantMap &config)
{
+ if (provider.isEmpty())
+ return;
QVariantList list = m_providers;
for (auto &item : list) {
auto m = item.toMap();
@@ -74,12 +102,18 @@ void SettingsStore::addProvider(const QString &provider, const QVariantMap &conf
save:
QSettings s;
s.setValue(kKeyProviders, m_providers);
+ s.sync();
emit providersChanged();
}
bool SettingsStore::hasVault(const QString &path) const
{
- return vaults().contains(path);
+ const QString clean = QDir::cleanPath(path);
+ const QStringList list = vaults();
+ for (const QString &v : list)
+ if (QDir::cleanPath(v) == clean)
+ return true;
+ return false;
}
void SettingsStore::clearProvider(const QString &provider)
@@ -93,5 +127,153 @@ void SettingsStore::clearProvider(const QString &provider)
m_providers = list;
QSettings s;
s.setValue(kKeyProviders, m_providers);
+ s.sync();
emit providersChanged();
+}
+
+QString SettingsStore::theme() const
+{
+ QSettings s;
+ const QString t = s.value(kKeyTheme, QStringLiteral("light")).toString();
+ return (t == "dark" || t == "light") ? t : QStringLiteral("light");
+}
+
+void SettingsStore::setTheme(const QString &t)
+{
+ const QString clean = (t == "dark") ? QStringLiteral("dark") : QStringLiteral("light");
+ QSettings s;
+ if (s.value(kKeyTheme).toString() == clean)
+ return;
+ s.setValue(kKeyTheme, clean);
+ s.sync();
+ emit themeChanged();
+}
+
+int SettingsStore::leftPanelWidth() const
+{
+ QSettings s;
+ return s.value(kKeyLeftW, 220).toInt();
+}
+
+void SettingsStore::setLeftPanelWidth(int w)
+{
+ if (w < 100) w = 100;
+ if (w > 800) w = 800;
+ QSettings s;
+ if (s.value(kKeyLeftW).toInt() == w)
+ return;
+ s.setValue(kKeyLeftW, w);
+ s.sync();
+ emit leftPanelWidthChanged();
+}
+
+int SettingsStore::rightPanelWidth() const
+{
+ QSettings s;
+ return s.value(kKeyRightW, 220).toInt();
+}
+
+void SettingsStore::setRightPanelWidth(int w)
+{
+ if (w < 100) w = 100;
+ if (w > 800) w = 800;
+ QSettings s;
+ if (s.value(kKeyRightW).toInt() == w)
+ return;
+ s.setValue(kKeyRightW, w);
+ s.sync();
+ emit rightPanelWidthChanged();
+}
+
+QString SettingsStore::viewModeForFile(const QString &vault, const QString &filePath) const
+{
+ if (vault.isEmpty() || filePath.isEmpty())
+ return QStringLiteral("edit");
+ QSettings s;
+ const QVariantMap root = s.value(kKeyViewModes).toMap();
+ const QString cleanVault = QDir::cleanPath(vault);
+ const QString cleanPath = QDir::cleanPath(filePath);
+ QString rel = cleanPath;
+ if (cleanPath.startsWith(cleanVault)) {
+ rel = cleanPath.mid(cleanVault.length());
+ while (rel.startsWith("/") || rel.startsWith("\\")) rel.remove(0,1);
+ } else {
+ rel = cleanPath;
+ }
+ const QString oldKey = cleanVault + "|" + rel;
+ if (root.contains(oldKey)) {
+ const QString m = root.value(oldKey).toString();
+ if (m == "preview" || m == "live" || m == "edit") return m;
+ }
+ const QString encVault = encodeKey(cleanVault);
+ const QString encRel = encodeRel(rel);
+ const QString encKey = encVault + "|" + encRel;
+ if (root.contains(encKey)) {
+ const QString m = root.value(encKey).toString();
+ if (m == "preview" || m == "live" || m == "edit") return m;
+ }
+ // Обратная совместимость: старый вложенный формат
+ QVariantMap vaultMap = root.value(encVault).toMap();
+ if (vaultMap.isEmpty())
+ vaultMap = root.value(cleanVault).toMap();
+ QString mode = vaultMap.value(encRel).toString();
+ if (mode.isEmpty())
+ mode = vaultMap.value(rel).toString();
+ if (mode == "preview" || mode == "live" || mode == "edit")
+ return mode;
+ return QStringLiteral("edit");
+}
+
+void SettingsStore::setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode)
+{
+ if (vault.isEmpty() || filePath.isEmpty())
+ return;
+ QString cleanMode = mode;
+ if (cleanMode != "preview" && cleanMode != "live")
+ cleanMode = "edit";
+ QSettings s;
+ QVariantMap root = s.value(kKeyViewModes).toMap();
+ const QString cleanVault = QDir::cleanPath(vault);
+ const QString cleanPath = QDir::cleanPath(filePath);
+ QString rel = cleanPath;
+ if (cleanPath.startsWith(cleanVault)) {
+ rel = cleanPath.mid(cleanVault.length());
+ while (rel.startsWith("/") || rel.startsWith("\\")) rel.remove(0,1);
+ } else {
+ rel = cleanPath;
+ }
+ const QString encVault = encodeKey(cleanVault);
+ const QString encRel = encodeRel(rel);
+ const QString encKey = encVault + "|" + encRel;
+ // мигрируем старые ключи
+ const QString oldKey = cleanVault + "|" + rel;
+ if (root.contains(oldKey))
+ root.remove(oldKey);
+ // мигрируем старый вложенный формат если есть
+ if (root.contains(encVault) && root.value(encVault).typeId() == QMetaType::QVariantMap) {
+ QVariantMap vm = root.value(encVault).toMap();
+ if (!vm.isEmpty()) {
+ // переносим все вложенные записи в плоский формат
+ for (auto it = vm.constBegin(); it != vm.constEnd(); ++it) {
+ const QString innerKey = encVault + "|" + it.key();
+ if (!root.contains(innerKey))
+ root.insert(innerKey, it.value());
+ }
+ root.remove(encVault);
+ }
+ }
+ if (root.contains(cleanVault) && root.value(cleanVault).typeId() == QMetaType::QVariantMap) {
+ QVariantMap vm = root.value(cleanVault).toMap();
+ root.remove(cleanVault);
+ for (auto it = vm.constBegin(); it != vm.constEnd(); ++it) {
+ const QString k = encodeKey(cleanVault) + "|" + encodeRel(it.key());
+ if (!root.contains(k))
+ root.insert(k, it.value());
+ }
+ }
+ if (root.value(encKey).toString() == cleanMode)
+ return;
+ root.insert(encKey, cleanMode);
+ s.setValue(kKeyViewModes, root);
+ s.sync();
}
\ No newline at end of file
diff --git a/src/core/SettingsStore.h b/src/core/SettingsStore.h
index e7eeaf9..8f43aa7 100644
--- a/src/core/SettingsStore.h
+++ b/src/core/SettingsStore.h
@@ -14,6 +14,9 @@ class SettingsStore : public QObject
Q_PROPERTY(QString currentVault READ currentVault WRITE setCurrentVault
NOTIFY currentVaultChanged)
Q_PROPERTY(QVariantList providers READ providers NOTIFY providersChanged)
+ Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
+ Q_PROPERTY(int leftPanelWidth READ leftPanelWidth WRITE setLeftPanelWidth NOTIFY leftPanelWidthChanged)
+ Q_PROPERTY(int rightPanelWidth READ rightPanelWidth WRITE setRightPanelWidth NOTIFY rightPanelWidthChanged)
public:
explicit SettingsStore(QObject *parent = nullptr);
@@ -33,10 +36,23 @@ public:
Q_INVOKABLE bool hasVault(const QString &path) const;
Q_INVOKABLE void clearProvider(const QString &provider);
+ QString theme() const;
+ void setTheme(const QString &t);
+ int leftPanelWidth() const;
+ void setLeftPanelWidth(int w);
+ int rightPanelWidth() const;
+ void setRightPanelWidth(int w);
+
+ Q_INVOKABLE QString viewModeForFile(const QString &vault, const QString &filePath) const;
+ Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode);
+
signals:
void vaultsChanged();
void currentVaultChanged();
void providersChanged();
+ void themeChanged();
+ void leftPanelWidthChanged();
+ void rightPanelWidthChanged();
private:
QVariantList m_providers;
diff --git a/src/core/VaultIndex.cpp b/src/core/VaultIndex.cpp
index af9dbf3..8fec9a2 100644
--- a/src/core/VaultIndex.cpp
+++ b/src/core/VaultIndex.cpp
@@ -1,13 +1,15 @@
#include "core/VaultIndex.h"
+#include
#include
#include
#include
#include
+#include
namespace {
-QStringList kIgnoredNames = {".git", ".flora", ".stfolder"};
+QStringList kIgnoredNames = {".git", ".flora", ".stfolder", ".DS_Store", "Thumbs.db"};
// Извлекает теги #tag из текста (пропуская блоки кода).
QStringList extractTags(const QString &text)
@@ -15,19 +17,24 @@ QStringList extractTags(const QString &text)
QStringList tags;
static const QRegularExpression tagRe(
QStringLiteral("(?:^|[^\\w#])#([\\p{L}\\w][\\p{L}\\w-]*)"));
+ static const QRegularExpression inlineCodeRe(QStringLiteral("`[^`]*`"));
bool inCode = false;
const QStringList lines = text.split(QLatin1Char('\n'));
- for (const QString &line : lines) {
- const QString t = line.trimmed();
- if (t.startsWith(QLatin1String("```"))) {
+ for (const QString &origLine : lines) {
+ const QString t = origLine.trimmed();
+ if (t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~"))) {
inCode = !inCode;
continue;
}
if (inCode)
continue;
- // Заголовок "# Заголовок" — не тег (после # идёт пробел)
- if (t.startsWith(QLatin1String("# ")) || t.startsWith(QLatin1String("## ")))
- continue;
+ // Убираем инлайн-код `code #tag` — теги внутри не считаем
+ QString line = origLine;
+ line.remove(inlineCodeRe);
+ // Не пропускаем заголовки целиком — только сам маркер "# " в начале,
+ // теги после текста заголовка всё равно должны учитываться
+ static const QRegularExpression headerMarkerRe(QStringLiteral("^\\s*#{1,6}\\s+"));
+ line.remove(headerMarkerRe);
auto it = tagRe.globalMatch(line);
while (it.hasNext()) {
const QString tag = it.next().captured(1);
@@ -98,14 +105,33 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c
return QString();
if (t.contains(QLatin1String("://")))
return QString(); // внешняя
+ // Отсекаем якорь #section и query ?
+ int hash = t.indexOf('#');
+ if (hash >= 0) t = t.left(hash).trimmed();
+ int q = t.indexOf('?');
+ if (q >= 0) t = t.left(q).trimmed();
+ if (t.isEmpty())
+ return QString();
+ // Защита от traversal
+ if (t.contains(QStringLiteral("..")))
+ return QString();
QStringList cands;
- cands << cleanDir(fileDir) + '/' + t
- << cleanDir(fileDir) + '/' + t + ".md";
- for (const QString &c : cands)
- if (QFileInfo::exists(c))
+ cands << QDir::cleanPath(cleanDir(fileDir) + '/' + t)
+ << QDir::cleanPath(cleanDir(fileDir) + '/' + t + ".md");
+ for (const QString &c : cands) {
+ if (QFileInfo::exists(c) && QFileInfo(c).isFile())
return QDir::cleanPath(c);
- // поиск по имени файла по всему хранилищу (без расширения)
- auto it = m_fileByBase.constFind(t);
+ }
+ // поиск по имени файла по всему хранилищу (без расширения), case-insensitive
+ const QString lower = t.toLower();
+ auto it = m_fileByBase.constFind(lower);
+ if (it != m_fileByBase.constEnd())
+ return it.value();
+ it = m_fileByBase.constFind(lower + ".md");
+ if (it != m_fileByBase.constEnd())
+ return it.value();
+ // fallback точный регистр
+ it = m_fileByBase.constFind(t);
if (it != m_fileByBase.constEnd())
return it.value();
it = m_fileByBase.constFind(t + ".md");
@@ -116,6 +142,20 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c
void VaultIndex::rebuild(const QString &vault)
{
+ if (vault.isEmpty() || !QDir(vault).exists()) {
+ m_nodes.clear();
+ m_edges.clear();
+ m_nodeById.clear();
+ m_tagsOfFile.clear();
+ m_linksOfFile.clear();
+ m_filesWithTag.clear();
+ m_fileByBase.clear();
+ m_fileCount = 0;
+ emit indexChanged();
+ return;
+ }
+ if (m_busy)
+ return;
m_busy = true;
emit busyChanged();
@@ -134,12 +174,27 @@ void VaultIndex::rebuild(const QString &vault)
m_fileCount = files.size();
for (const QString &f : files) {
const QString base = QFileInfo(f).completeBaseName();
+ const QString key = base.toLower();
+ if (!base.isEmpty() && !m_fileByBase.contains(key))
+ m_fileByBase.insert(key, f);
+ // также храним оригинал для точного поиска
if (!base.isEmpty() && !m_fileByBase.contains(base))
m_fileByBase.insert(base, f);
}
// Проход 2: узлы/теги/ссылки
for (const QString &f : files)
parseFile(f);
+ // Дедупликация рёбер
+ QSet seen;
+ QVariantList uniq;
+ for (const auto &e : std::as_const(m_edges)) {
+ const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString();
+ if (!seen.contains(k)) {
+ seen.insert(k);
+ uniq.append(e);
+ }
+ }
+ m_edges = uniq;
m_busy = false;
emit busyChanged();
@@ -177,8 +232,13 @@ void VaultIndex::parseFile(const QString &path)
ensureNode(key, QFileInfo(path).fileName(), QStringLiteral("file"));
{
const QString base = QFileInfo(path).completeBaseName();
- if (!base.isEmpty() && !m_fileByBase.contains(base))
- m_fileByBase.insert(base, path);
+ if (!base.isEmpty()) {
+ const QString lower = base.toLower();
+ if (!m_fileByBase.contains(lower))
+ m_fileByBase.insert(lower, path);
+ if (!m_fileByBase.contains(base))
+ m_fileByBase.insert(base, path);
+ }
}
// Теги
@@ -191,12 +251,19 @@ void VaultIndex::parseFile(const QString &path)
m_filesWithTag[tag].append(path);
}
- // Ссылки [[X]] и [text](rel)
+ // Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki)
QVariantList links;
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
auto wi = wikiRe.globalMatch(text);
while (wi.hasNext()) {
- const QString target = wi.next().captured(1);
+ QString target = wi.next().captured(1);
+ // [[file|alias]] → берём file
+ const int pipe = target.indexOf('|');
+ if (pipe >= 0)
+ target = target.left(pipe).trimmed();
+ target = target.trimmed();
+ if (target.isEmpty())
+ continue;
const QString resolved = resolveLink(fileDir, target);
if (!resolved.isEmpty()) {
links.append(QVariantMap{{"text", target}, {"path", resolved}});
diff --git a/src/git/GitManager.cpp b/src/git/GitManager.cpp
index fe8c04b..ef27d5d 100644
--- a/src/git/GitManager.cpp
+++ b/src/git/GitManager.cpp
@@ -27,22 +27,25 @@ struct CommitInfo {
} // namespace
+bool GitManager::s_gitInitialized = false;
+
GitManager::GitManager(QObject *parent)
: QObject(parent)
{
- if (!m_gitInitialized) {
+ if (!s_gitInitialized) {
git_libgit2_init();
- m_gitInitialized = true;
+ s_gitInitialized = true;
}
}
QString GitManager::repoExists(const QString &path)
{
+ const QString clean = QDir::cleanPath(path);
git_repository *repo = nullptr;
- if (git_repository_open(&repo, path.toUtf8().constData()) < 0)
+ if (git_repository_open(&repo, clean.toUtf8().constData()) < 0)
return QString();
git_repository_free(repo);
- return path;
+ return clean;
}
void GitManager::setRepoIdentity(git_repository *repo)
@@ -72,11 +75,18 @@ void GitManager::setLastError(const QString &msg)
bool GitManager::initRepo(const QString &path)
{
- const QByteArray p = path.toUtf8();
+ const QString clean = QDir::cleanPath(path.trimmed());
+ if (clean.isEmpty()) {
+ setLastError(QStringLiteral("Путь к хранилищу пустой"));
+ return false;
+ }
+ const QByteArray p = clean.toUtf8();
git_repository *repo = nullptr;
if (git_repository_open(&repo, p.constData()) == 0) {
+ setRepoIdentity(repo);
git_repository_free(repo);
+ setLastError(QString());
return true; // .git уже есть
}
@@ -98,18 +108,89 @@ bool GitManager::initRepo(const QString &path)
bool GitManager::hasCommits(const QString &path) const
{
+ const QString clean = QDir::cleanPath(path);
git_repository *repo = nullptr;
- if (git_repository_open(&repo, path.toUtf8().constData()) < 0)
+ if (git_repository_open(&repo, clean.toUtf8().constData()) < 0)
return false;
const bool has = git_repository_head_unborn(repo) == 0;
git_repository_free(repo);
return has;
}
+QString GitManager::fileGitStatus(const QString &vaultPath, const QString &filePath) const
+{
+ if (vaultPath.isEmpty() || filePath.isEmpty())
+ return QStringLiteral("—");
+ const QString vault = QDir::cleanPath(vaultPath);
+ const QString file = QDir::cleanPath(filePath);
+ if (!file.startsWith(vault + '/') && file != vault)
+ return QStringLiteral("—");
+ git_repository *repo = nullptr;
+ if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
+ return QStringLiteral("—");
+
+ // относительный путь внутри репозитория
+ QString rel = file;
+ if (rel == vault) {
+ git_repository_free(repo);
+ return QStringLiteral("—");
+ }
+ if (rel.startsWith(vault)) {
+ rel = rel.mid(vault.length());
+ while (rel.startsWith("/") || rel.startsWith("\\"))
+ rel.remove(0, 1);
+ }
+ if (rel.isEmpty()) {
+ git_repository_free(repo);
+ return QStringLiteral("—");
+ }
+
+ unsigned int status = 0;
+ const int rc = git_status_file(&status, repo, rel.toUtf8().constData());
+ git_repository_free(repo);
+
+ if (rc != 0) {
+ // файл не найден в индексе или ошибка — считаем неотслеживаемым если файл существует
+ if (QFile::exists(filePath))
+ return QStringLiteral("неотслеживается");
+ return QStringLiteral("—");
+ }
+
+ if (status == GIT_STATUS_CURRENT)
+ return QStringLiteral("чисто");
+ if (status & GIT_STATUS_CONFLICTED)
+ return QStringLiteral("конфликт");
+ if (status & GIT_STATUS_WT_NEW)
+ return QStringLiteral("неотслеживается");
+ if (status & GIT_STATUS_INDEX_NEW)
+ return QStringLiteral("в индексе (новый)");
+ if (status & GIT_STATUS_INDEX_MODIFIED)
+ return QStringLiteral("в индексе");
+ if (status & GIT_STATUS_WT_MODIFIED)
+ return QStringLiteral("изменён");
+ if (status & GIT_STATUS_WT_DELETED)
+ return QStringLiteral("удалён");
+ if (status & GIT_STATUS_IGNORED)
+ return QStringLiteral("игнорируется");
+ return QStringLiteral("чисто");
+}
+
+QString GitManager::fileGitStatusShort(const QString &vaultPath, const QString &filePath) const
+{
+ const QString s = fileGitStatus(vaultPath, filePath);
+ if (s == "чисто") return QStringLiteral("чисто");
+ if (s == "изменён") return QStringLiteral("изменён");
+ if (s == "неотслеживается") return QStringLiteral("новый");
+ if (s.contains("конфликт")) return QStringLiteral("конфликт");
+ if (s == "—") return QStringLiteral("—");
+ return s;
+}
+
bool GitManager::commitAll(const QString &path, const QString &message)
{
+ const QString cleanPath = QDir::cleanPath(path);
CommitInfo info;
- if (git_repository_open(&info.repo, path.toUtf8().constData()) < 0) {
+ if (git_repository_open(&info.repo, cleanPath.toUtf8().constData()) < 0) {
setLastError(QStringLiteral("Хранилище не является git-репозиторием"));
return false;
}
@@ -119,18 +200,16 @@ bool GitManager::commitAll(const QString &path, const QString &message)
return false;
}
- // Добавляем все файлы, включая новые (с учётом .gitignore)
- git_index_add_all(info.index, nullptr, 0, nullptr, nullptr);
+ // Добавляем все файлы, включая новые и удаления (с учётом .gitignore)
+ git_index_add_all(info.index, nullptr, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr);
const int wrc = git_index_write(info.index);
if (wrc < 0) {
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
return false;
}
- if (git_index_entrycount(info.index) == 0) {
- setLastError(QStringLiteral("Нет изменений для коммита"));
- return false;
- }
+ // Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов,
+ // что тоже нужно коммитить. Проверяем равенство дерева ниже.
// Создаём дерево
git_tree *tree = nullptr;
diff --git a/src/git/GitManager.h b/src/git/GitManager.h
index b019026..b3aa893 100644
--- a/src/git/GitManager.h
+++ b/src/git/GitManager.h
@@ -23,6 +23,12 @@ public:
// Есть ли в хранилище хотя бы один коммит.
Q_INVOKABLE bool hasCommits(const QString &path) const;
+ // Статус файла в git: "чисто", "изменён", "неотслеживается", "в индексе", "конфликт", "—" (не git)
+ Q_INVOKABLE QString fileGitStatus(const QString &vaultPath, const QString &filePath) const;
+
+ // Короткий статус для отображения в UI: "сохранено" / "изменён" / "новый" etc
+ Q_INVOKABLE QString fileGitStatusShort(const QString &vaultPath, const QString &filePath) const;
+
// Последнее сообщение об ошибке (русскоязычное, из каталога ошибок).
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
@@ -38,5 +44,5 @@ private:
void setLastError(const QString &msg);
QString m_lastError;
- bool m_gitInitialized = false;
+ static bool s_gitInitialized;
};
\ No newline at end of file
diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp
index dd07c96..4050ef9 100644
--- a/src/markdown/MdParser.cpp
+++ b/src/markdown/MdParser.cpp
@@ -14,10 +14,15 @@
#include
#include
+#include
+#include
#include
+#include
#include
#include
+#include
#include
+#include
#include
using KSyntaxHighlighting::AbstractHighlighter;
@@ -39,12 +44,52 @@ QString esc(const QString &s)
case '<': out += "<"; break;
case '>': out += ">"; break;
case '"': out += """; break;
+ case '\'': out += "'"; break;
default: out += c; break;
}
}
return out;
}
+bool isSafeHtmlTag(const QString &html)
+{
+ QString t = html.trimmed();
+ if (!t.startsWith(QLatin1Char('<')))
+ return false;
+ // извлекаем имя тега: ? tag
+ static const QRegularExpression re(QStringLiteral("^?\\s*([a-zA-Z0-9]+)"));
+ auto m = re.match(t);
+ if (!m.hasMatch())
+ return false;
+ const QString tag = m.captured(1).toLower();
+ static const QSet safe = {
+ "sup", "sub", "span", "u", "mark", "small", "s", "del", "ins",
+ "abbr", "cite", "q", "kbd", "code", "b", "i", "em", "strong",
+ "br", "hr", "a", "font"
+ };
+ if (!safe.contains(tag))
+ return false;
+ // блокируем любые event-атрибуты (on*) и javascript: / data:text/html
+ static const QRegularExpression onEventRe(QStringLiteral("\\bon\\w+\\s*="), QRegularExpression::CaseInsensitiveOption);
+ if (onEventRe.match(t).hasMatch())
+ return false;
+ if (t.contains(QStringLiteral("javascript:"), Qt::CaseInsensitive)
+ || t.contains(QStringLiteral("data:text/html"), Qt::CaseInsensitive)
+ || t.contains(QStringLiteral("vbscript:"), Qt::CaseInsensitive))
+ return false;
+ // блокируем style с expression/moz-binding/behavior
+ if (t.contains(QStringLiteral("expression"), Qt::CaseInsensitive)
+ || t.contains(QStringLiteral("moz-binding"), Qt::CaseInsensitive)
+ || t.contains(QStringLiteral("behavior"), Qt::CaseInsensitive))
+ return false;
+ return true;
+}
+
+QString safeHtmlOrEscaped(const QString &html)
+{
+ return isSafeHtmlTag(html) ? html : esc(html);
+}
+
QString fileHrefFor(const QString &path)
{
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
@@ -64,17 +109,27 @@ QString imageDataUri(const QString &path)
if (!f.open(QIODevice::ReadOnly) || f.size() > 3 * 1024 * 1024)
return QString();
const QByteArray data = f.readAll();
+ // Пытаемся определить формат через QImageReader, fallback — по расширению
QImageReader reader(path);
- const QByteArray fmt = reader.format().toLower();
+ QByteArray fmt = reader.format().toLower();
+ if (fmt.isEmpty()) {
+ const QString ext = QFileInfo(path).suffix().toLower();
+ fmt = ext.toUtf8();
+ }
const QString mime = fmt == "jpg" || fmt == "jpeg" ? QStringLiteral("image/jpeg")
: fmt == "png" ? QStringLiteral("image/png")
: fmt == "gif" ? QStringLiteral("image/gif")
: fmt == "svg" ? QStringLiteral("image/svg+xml")
+ : fmt == "svgz" ? QStringLiteral("image/svg+xml")
: fmt == "webp" ? QStringLiteral("image/webp")
: fmt == "bmp" ? QStringLiteral("image/bmp")
+ : fmt == "ico" ? QStringLiteral("image/x-icon")
: QString();
if (mime.isEmpty())
return QString();
+ // Базовое определение содержимого SVG — должен содержать