diff --git a/CMakeLists.txt b/CMakeLists.txt index a987179..7fc7284 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,6 +131,7 @@ qt_add_qml_module(flora qml/dialogs/ConfirmDialog.qml qml/dialogs/TableEditor.qml qml/dialogs/HistoryDialog.qml + qml/dialogs/SettingsDialog.qml ) # Theme как синглтон diff --git a/qml/MainWindow.qml b/qml/MainWindow.qml index 6ff7dd5..a43b110 100644 --- a/qml/MainWindow.qml +++ b/qml/MainWindow.qml @@ -49,6 +49,15 @@ ApplicationWindow { // статус можно показать в UI, пока просто игнорируем } + function applyAutosavePreset() { + if (settingsStore.autosaveEnabled) { + documentManager.setAutosaveIntervalMs(settingsStore.autosaveDiskMs) + documentManager.setAutosaveEnabled(true) + } else { + documentManager.setAutosaveEnabled(false) + } + } + Component.onCompleted: { if (windowVault.length > 0) { fileStore.openVault(windowVault) @@ -58,6 +67,11 @@ ApplicationWindow { if (vaultManager.vaults.length === 0) { welcomeWizard.open() } + applyAutosavePreset() + } + Connections { + target: settingsStore + function onAutosavePresetChanged() { applyAutosavePreset() } } menuBar: MenuBar { @@ -72,6 +86,7 @@ ApplicationWindow { Action { text: qsTr("В PDF…"); enabled: false } } MenuSeparator { } + Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() } Action { text: qsTr("Выход"); onTriggered: window.close() } } Menu { @@ -164,6 +179,10 @@ ApplicationWindow { } } + SettingsDialog { + id: settingsDialog + } + Popup { id: vaultSwitcher x: 12 diff --git a/qml/dialogs/SettingsDialog.qml b/qml/dialogs/SettingsDialog.qml new file mode 100644 index 0000000..c3edcee --- /dev/null +++ b/qml/dialogs/SettingsDialog.qml @@ -0,0 +1,224 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Flora + +Dialog { + id: root + + // Наведённый вариант (для живой подсказки), иначе — выбранный + property string hoveredPreset: "" + function presetById(id) { + for (let i = 0; i < presets.length; ++i) + if (presets[i].id === id) return presets[i] + return presets[1] + } + property var shownPreset: presetById(hoveredPreset.length > 0 ? hoveredPreset : settingsStore.autosavePreset) + + property var presets: [ + { + id: "safe", name: qsTr("Максимум сохранности"), + desc: qsTr("На диск — каждые 2 с, версия в историю — после 5 с паузы"), + pros: [qsTr("Текст почти невозможно потерять"), qsTr("Подробная история — легко откатить любой момент")], + cons: [qsTr("Больше коммитов при долгой печати"), qsTr("Чаще пишет на диск")], + meters: [["disk", 5], ["commits", 4], ["load", 4]] + }, + { + id: "balanced", name: qsTr("Сбалансированный"), + desc: qsTr("На диск — каждые 5 с, версия в историю — после 15 с паузы"), + pros: [qsTr("Разумный баланс потерь и чистоты истории"), qsTr("Версии только после паузы — без мусора")], + cons: [qsTr("До 5 секунд текста живёт только в памяти")], + meters: [["disk", 3], ["commits", 3], ["load", 3]] + }, + { + id: "eco", name: qsTr("Экономный"), + desc: qsTr("На диск — каждые 10 с, версия в историю — после минуты паузы"), + pros: [qsTr("Минимум нагрузки — для слабых дисков и огромных хранилищ"), qsTr("История чистая, только вехи")], + cons: [qsTr("До 10 секунд текста живёт только в памяти"), qsTr("Промежуточные мысли в историю не попадут")], + meters: [["disk", 2], ["commits", 2], ["load", 2]] + }, + { + id: "manual", name: qsTr("Вручную"), + desc: qsTr("Без таймеров: Ctrl+S сохраняет на диск и делает версию"), + pros: [qsTr("Полный контроль, ноль фоновой работы")], + cons: [qsTr("Забыли Ctrl+S — потеряли"), qsTr("История растёт только от ваших рук")], + meters: [["disk", 1], ["commits", 1], ["load", 1]] + } + ] + + function meterName(key) { + if (key === "disk") return qsTr("Запись на диск") + if (key === "commits") return qsTr("Коммиты в историю") + return qsTr("Фоновая нагрузка") + } + + title: qsTr("Настройки") + modal: true + width: 640 + height: 620 + anchors.centerIn: parent + closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside + standardButtons: Dialog.Close + background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 } + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + // Подсказка: как читать шкалу + Rectangle { + Layout.fillWidth: true + color: Theme.bgAlt + border.color: Theme.border + radius: 8 + Layout.preferredHeight: hintLabel.implicitHeight + 16 + Label { + id: hintLabel + anchors.fill: parent + anchors.margins: 8 + wrapMode: Text.Wrap + color: Theme.textSecondary + font.pixelSize: 11 + text: qsTr("Как выбирать: чем меньше галочек — тем проще и легче решение. " + + "Берите самое простое, которого вам хватает: обычно это «Сбалансированный», " + + "для слабого железа или огромных хранилищ — «Экономный».") + } + } + + Label { + text: qsTr("Автосохранение") + font.bold: true + color: Theme.text + font.pixelSize: 13 + } + + ButtonGroup { id: presetGroup } + + Repeater { + model: root.presets + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + height: 52 + radius: 8 + color: modelData.id === settingsStore.autosavePreset ? Theme.surfaceSelected + : (rowHover.hovered ? Theme.surfaceHover : Theme.bgAlt) + border.color: modelData.id === settingsStore.autosavePreset ? Theme.accent : Theme.border + border.width: modelData.id === settingsStore.autosavePreset ? 1 : 0 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + RadioButton { + checked: modelData.id === settingsStore.autosavePreset + ButtonGroup.group: presetGroup + onClicked: settingsStore.autosavePreset = modelData.id + } + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: modelData.name + font.bold: true + color: Theme.text + font.pixelSize: 12 + } + Label { + text: modelData.desc + color: Theme.textMuted + font.pixelSize: 11 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + HoverHandler { + id: rowHover + onHoveredChanged: { + if (hovered) root.hoveredPreset = modelData.id + else if (root.hoveredPreset === modelData.id) root.hoveredPreset = "" + } + } + TapHandler { + onTapped: settingsStore.autosavePreset = modelData.id + } + } + } + + // Детали: плюсы/минусы + шкалы выбранного (или наведённого) варианта + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.bgAlt + border.color: Theme.border + radius: 8 + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 6 + Label { + text: root.shownPreset.name + font.bold: true + color: Theme.text + font.pixelSize: 12 + } + Repeater { + model: root.shownPreset.pros + delegate: Label { + required property string modelData + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Theme.successText + font.pixelSize: 11 + text: "+ " + modelData + } + } + Repeater { + model: root.shownPreset.cons + delegate: Label { + required property string modelData + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Theme.errorText + font.pixelSize: 11 + text: "− " + modelData + } + } + Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider } + Repeater { + model: root.shownPreset.meters + delegate: RowLayout { + id: meterRow + required property var modelData + property string meterKey: modelData[0] + property int meterLevel: modelData[1] + Layout.fillWidth: true + spacing: 8 + Label { + text: root.meterName(meterRow.meterKey) + color: Theme.textSecondary + font.pixelSize: 11 + Layout.preferredWidth: 140 + } + Row { + spacing: 2 + Repeater { + model: 5 + Label { + required property int index + text: "✓" + font.pixelSize: 11 + font.bold: true + color: index < meterRow.meterLevel ? Theme.accent : Theme.textFaint + } + } + } + } + } + Item { Layout.fillHeight: true } + } + } + } +} diff --git a/qml/panels/EditorPanel.qml b/qml/panels/EditorPanel.qml index bebe576..0a6f343 100644 --- a/qml/panels/EditorPanel.qml +++ b/qml/panels/EditorPanel.qml @@ -782,23 +782,26 @@ Item { } refreshGitStatus() } + function armAutoCommit(path) { + // Коммит только после паузы в печати (idle), а не на каждое сохранение + if (!settingsStore.autocommitEnabled || !fileStore.ready || !path || path.length === 0) + return + autoCommitDebounce.filePath = path + autoCommitDebounce.restart() + } Connections { target: documentManager function onActiveIndexChanged() { root.loadContent() } - function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() } + function onContentChanged(idx) { + if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent() + refreshGitStatus() + if (idx >= 0) root.armAutoCommit(documentManager.pathAt(idx)) + } function onOpenTabsChanged() { root.loadContent() } function onDirtyFlagChanged() { refreshGitStatus() } function onDocumentSaved(idx) { refreshGitStatus() - // Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю - if (fileStore.ready && idx >= 0) { - const p = documentManager.pathAt(idx) - if (p && p.length>0) { - // Небольшая задержка чтобы не спамить на каждый автосейв - autoCommitDebounce.filePath = p - autoCommitDebounce.restart() - } - } + if (idx >= 0) root.armAutoCommit(documentManager.pathAt(idx)) } } Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } } @@ -806,9 +809,9 @@ Item { Timer { id: autoCommitDebounce property string filePath: "" - interval: 2000 + interval: settingsStore.autocommitIdleMs onTriggered: { - if (fileStore.ready && filePath.length>0) { + if (settingsStore.autocommitEnabled && fileStore.ready && filePath.length>0) { gitManager.autoCommit(fileStore.vault, filePath) refreshGitStatus() } @@ -996,7 +999,16 @@ Item { TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } } - Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } } + Shortcut { sequence: "Ctrl+S"; onActivated: { + const dirty = [] + for (let i = 0; i < documentManager.openTabs.length; ++i) + if (documentManager.isDirty(i)) dirty.push(documentManager.pathAt(i)) + documentManager.saveAll() + // В ручном режиме Ctrl+S и сохраняет, и коммитит изменённые файлы + if (!settingsStore.autosaveEnabled && fileStore.ready) + for (const p of dirty) gitManager.autoCommit(fileStore.vault, p) + refreshGitStatus() + } } Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) } Shortcut { sequence: "Ctrl+H"; onActivated: { const p = documentManager.pathAt(documentManager.activeIndex) diff --git a/qml/panels/FileTreePanel.qml b/qml/panels/FileTreePanel.qml index 9ab2520..48575fa 100644 --- a/qml/panels/FileTreePanel.qml +++ b/qml/panels/FileTreePanel.qml @@ -151,6 +151,7 @@ Item { if (drop.source && drop.source.dragPath && fileStore.ready) { const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault) if (err.length > 0) showStatus(err, false) + else vaultIndex.syncIncremental() } } } @@ -282,6 +283,7 @@ Item { if (!rowRect.isDir || !drop.source || !drop.source.dragPath) return const err = fileStore.moveItem(drop.source.dragPath, rowRect.path) if (err.length > 0) root.showStatus(err, false) + else vaultIndex.syncIncremental() } } @@ -439,9 +441,14 @@ Item { 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) + if (err.length>0) { + showStatus(err,false) + } else { + if (pendingCreate.kind!=="rename") showStatus(qsTr("Создано: %1").arg(name), true) + vaultIndex.syncIncremental() + } pendingCreate.parent="" } } - ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else showStatus(qsTr("Удалено"), true) } } + ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else { showStatus(qsTr("Удалено"), true); vaultIndex.syncIncremental() } } } } diff --git a/qml/panels/RightPanel.qml b/qml/panels/RightPanel.qml index 213b5d6..09e1568 100644 --- a/qml/panels/RightPanel.qml +++ b/qml/panels/RightPanel.qml @@ -26,16 +26,31 @@ Item { target: fileStore function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) } function onReadyChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) } + // Внешнее изменение (синхронизация/другая программа): досинхронизировать индекс + // по mtime — дёшево, без полного рескана + function onExternalChanged() { if (fileStore.ready) vaultIndex.syncIncremental() } } Connections { target: documentManager function onActiveIndexChanged() { root.refreshLinksTags() } function onOpenTabsChanged() { root.refreshLinksTags() } - function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); rebuildDebounce.restart() } } - function onDocumentSaved() { rebuildDebounce.restart() } + function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } } + function onDocumentSaved(idx, path) { + if (fileStore.ready && path && path.length > 0) vaultIndex.updateFile(path) + } } Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } } - Timer { id: rebuildDebounce; interval: 800; onTriggered: if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) } + // Правка активного файла: обновить только его (один парс), а не всё хранилище + Timer { + id: indexDebounce + interval: 800 + onTriggered: { + if (fileStore.ready && documentManager.activeIndex >= 0) { + const p = documentManager.pathAt(documentManager.activeIndex) + if (p && p.length > 0) vaultIndex.updateFile(p) + } + } + } // Делаем панели ресайзабельными внутри правой колонки — увеличенная зона по всей ширине SplitView { diff --git a/src/core/DocumentManager.cpp b/src/core/DocumentManager.cpp index 399a6eb..738eb04 100644 --- a/src/core/DocumentManager.cpp +++ b/src/core/DocumentManager.cpp @@ -63,6 +63,21 @@ void DocumentManager::setSideBySide(bool on) emit sideBySideChanged(); } +bool DocumentManager::autosaveEnabled() const { return m_autosaveEnabled; } +void DocumentManager::setAutosaveEnabled(bool on) +{ + if (m_autosaveEnabled == on) + return; + m_autosaveEnabled = on; + if (m_autosave) { + if (on) + m_autosave->start(); + else + m_autosave->stop(); + } + emit autosaveEnabledChanged(); +} + int DocumentManager::autosaveIntervalMs() const { return m_autosaveMs; } void DocumentManager::setAutosaveIntervalMs(int ms) { diff --git a/src/core/DocumentManager.h b/src/core/DocumentManager.h index d1c3073..74edee0 100644 --- a/src/core/DocumentManager.h +++ b/src/core/DocumentManager.h @@ -17,6 +17,8 @@ class DocumentManager : public QObject Q_PROPERTY(bool sideBySide READ sideBySide WRITE setSideBySide NOTIFY sideBySideChanged) Q_PROPERTY(int autosaveIntervalMs READ autosaveIntervalMs WRITE setAutosaveIntervalMs NOTIFY autosaveIntervalChanged) + Q_PROPERTY(bool autosaveEnabled READ autosaveEnabled WRITE setAutosaveEnabled + NOTIFY autosaveEnabledChanged) Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged) public: @@ -31,6 +33,8 @@ public: Q_INVOKABLE void setSideBySide(bool on); int autosaveIntervalMs() const; Q_INVOKABLE void setAutosaveIntervalMs(int ms); + bool autosaveEnabled() const; + Q_INVOKABLE void setAutosaveEnabled(bool on); QString lastStatus() const; // Открывает документ (при необходимости создаётся вкладка). Возвращает индекс. @@ -60,6 +64,7 @@ signals: void secondaryIndexChanged(); void sideBySideChanged(); void autosaveIntervalChanged(); + void autosaveEnabledChanged(); void lastStatusChanged(); // Уведомляет вкладку о смене признака «изменено». @@ -89,6 +94,7 @@ public: int m_secondary = -1; bool m_sideBySide = false; int m_autosaveMs = 5000; + bool m_autosaveEnabled = true; 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 30bc8e1..c5de68d 100644 --- a/src/core/FileStore.cpp +++ b/src/core/FileStore.cpp @@ -12,7 +12,7 @@ FileStore::FileStore(QObject *parent) { m_debounce.setSingleShot(true); m_debounce.setInterval(300); - connect(&m_debounce, &QTimer::timeout, this, &FileStore::rebuild); + connect(&m_debounce, &QTimer::timeout, this, &FileStore::onExternalTimeout); connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileStore::scheduleRebuild); } @@ -223,6 +223,12 @@ void FileStore::scheduleRebuild() m_debounce.start(); } +void FileStore::onExternalTimeout() +{ + rebuild(); + emit externalChanged(); +} + void FileStore::toggleExpand(int row) { if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir) diff --git a/src/core/FileStore.h b/src/core/FileStore.h index 1bf5cc3..2543152 100644 --- a/src/core/FileStore.h +++ b/src/core/FileStore.h @@ -65,6 +65,9 @@ signals: void readyChanged(); void fileActivated(const QString &path); void statusMessage(const QString &message); + // Сработало внешнее изменение (синхронизация/другая программа) — дерево уже + // перестроено; собственные операции этот сигнал не вызывают. + void externalChanged(); private: struct Row { @@ -78,6 +81,7 @@ private: }; void rebuild(); + void onExternalTimeout(); void collectDir(const QString &dir, int depth, QVector &out) const; bool isIgnored(const QString &path) const; int rowOf(const QString &path) const; diff --git a/src/core/SettingsStore.cpp b/src/core/SettingsStore.cpp index 07a40ca..6f434da 100644 --- a/src/core/SettingsStore.cpp +++ b/src/core/SettingsStore.cpp @@ -14,6 +14,7 @@ constexpr auto kKeyTheme = "ui/theme"; constexpr auto kKeyLeftW = "ui/leftPanelWidth"; constexpr auto kKeyRightW = "ui/rightPanelWidth"; constexpr auto kKeyViewModes = "ui/viewModes"; +constexpr auto kKeyAutosavePreset = "autosave/preset"; QString encodeKey(const QString &s) { // Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini @@ -276,4 +277,53 @@ void SettingsStore::setViewModeForFile(const QString &vault, const QString &file root.insert(encKey, cleanMode); s.setValue(kKeyViewModes, root); s.sync(); -} \ No newline at end of file +} +QString SettingsStore::autosavePreset() const +{ + QSettings s; + const QString p = s.value(kKeyAutosavePreset, QStringLiteral("balanced")).toString(); + if (p == "safe" || p == "balanced" || p == "eco" || p == "manual") + return p; + return QStringLiteral("balanced"); +} + +void SettingsStore::setAutosavePreset(const QString &p) +{ + QString clean = p.trimmed(); + if (clean != "safe" && clean != "balanced" && clean != "eco" && clean != "manual") + clean = QStringLiteral("balanced"); + QSettings s; + if (s.value(kKeyAutosavePreset).toString() == clean) + return; + s.setValue(kKeyAutosavePreset, clean); + s.sync(); + emit autosavePresetChanged(); +} + +int SettingsStore::autosaveDiskMs() const +{ + const QString p = autosavePreset(); + if (p == "safe") return 2000; + if (p == "eco") return 10000; + if (p == "manual") return 0; + return 5000; // balanced +} + +int SettingsStore::autocommitIdleMs() const +{ + const QString p = autosavePreset(); + if (p == "safe") return 5000; + if (p == "eco") return 60000; + if (p == "manual") return 0; + return 15000; // balanced +} + +bool SettingsStore::autosaveEnabled() const +{ + return autosavePreset() != "manual"; +} + +bool SettingsStore::autocommitEnabled() const +{ + return autosavePreset() != "manual"; +} diff --git a/src/core/SettingsStore.h b/src/core/SettingsStore.h index 8f43aa7..ffe471e 100644 --- a/src/core/SettingsStore.h +++ b/src/core/SettingsStore.h @@ -17,6 +17,14 @@ class SettingsStore : public QObject 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) + // Пресет автосохранения: "safe" | "balanced" | "eco" | "manual". + // Производные значения читает UI (таймеры) и тесты. + Q_PROPERTY(QString autosavePreset READ autosavePreset WRITE setAutosavePreset + NOTIFY autosavePresetChanged) + Q_PROPERTY(int autosaveDiskMs READ autosaveDiskMs NOTIFY autosavePresetChanged) + Q_PROPERTY(int autocommitIdleMs READ autocommitIdleMs NOTIFY autosavePresetChanged) + Q_PROPERTY(bool autosaveEnabled READ autosaveEnabled NOTIFY autosavePresetChanged) + Q_PROPERTY(bool autocommitEnabled READ autocommitEnabled NOTIFY autosavePresetChanged) public: explicit SettingsStore(QObject *parent = nullptr); @@ -43,6 +51,13 @@ public: int rightPanelWidth() const; void setRightPanelWidth(int w); + QString autosavePreset() const; + void setAutosavePreset(const QString &p); + int autosaveDiskMs() const; + int autocommitIdleMs() const; + bool autosaveEnabled() const; + bool autocommitEnabled() const; + Q_INVOKABLE QString viewModeForFile(const QString &vault, const QString &filePath) const; Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode); @@ -53,6 +68,7 @@ signals: void themeChanged(); void leftPanelWidthChanged(); void rightPanelWidthChanged(); + void autosavePresetChanged(); private: QVariantList m_providers; diff --git a/src/core/VaultIndex.cpp b/src/core/VaultIndex.cpp index 8fec9a2..a0abac6 100644 --- a/src/core/VaultIndex.cpp +++ b/src/core/VaultIndex.cpp @@ -140,53 +140,11 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c return QString(); } -void VaultIndex::rebuild(const QString &vault) +void VaultIndex::deduplicateEdges() { - 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(); - - m_nodes.clear(); - m_edges.clear(); - m_nodeById.clear(); - m_tagsOfFile.clear(); - m_linksOfFile.clear(); - m_filesWithTag.clear(); - m_fileByBase.clear(); - m_fileCount = 0; - - // Проход 1: собрать все md-файлы и карту имён - QStringList files; - collectFiles(vault, 0, files); - 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; + uniq.reserve(m_edges.size()); 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)) { @@ -195,12 +153,258 @@ void VaultIndex::rebuild(const QString &vault) } } m_edges = uniq; +} + +bool VaultIndex::shouldIndex(const QString &cleanPath) const +{ + if (m_vault.isEmpty()) + return false; + if (cleanPath != m_vault && !cleanPath.startsWith(m_vault + '/')) + return false; + const QFileInfo fi(cleanPath); + if (!fi.exists() || !fi.isFile()) + return false; + if (kIgnoredNames.contains(fi.fileName())) + return false; + return fi.suffix().compare(QLatin1String("md"), Qt::CaseInsensitive) == 0; +} + +VaultIndex::FileMeta VaultIndex::metaOf(const QString &cleanPath) +{ + const QFileInfo fi(cleanPath); + return {fi.lastModified(), fi.size()}; +} + +void VaultIndex::purgeOutgoing(const QString &cleanPath) +{ + // Теги файла + const QStringList oldTags = m_tagsOfFile.take(cleanPath); + for (const QString &tag : oldTags) { + auto it = m_filesWithTag.find(tag); + if (it != m_filesWithTag.end()) { + it.value().removeAll(cleanPath); + if (it.value().isEmpty()) + m_filesWithTag.erase(it); + } + } + // Исходящие рёбра (входящие от других файлов не трогаем — они всё ещё валидны) + const QString key = nodeKeyFor(cleanPath, true); + QVariantList kept; + kept.reserve(m_edges.size()); + for (const auto &e : std::as_const(m_edges)) { + if (e.toMap().value("from").toString() != key) + kept.append(e); + } + m_edges = kept; + m_linksOfFile.remove(cleanPath); + m_unresolvedByFile.remove(cleanPath); +} + +void VaultIndex::dropNode(const QString &key) +{ + auto it = m_nodeById.find(key); + if (it == m_nodeById.end()) + return; + m_nodes.removeAt(it.value()); + m_nodeById.clear(); + for (int i = 0; i < m_nodes.size(); ++i) + m_nodeById.insert(m_nodes[i].toMap().value("key").toString(), i); +} + +void VaultIndex::dropFile(const QString &cleanPath) +{ + const QString key = nodeKeyFor(cleanPath, true); + purgeOutgoing(cleanPath); + // Входящие рёбра и ссылки других файлов на удалённый — тоже убрать + QVariantList kept; + kept.reserve(m_edges.size()); + for (const auto &e : std::as_const(m_edges)) { + if (e.toMap().value("to").toString() != key) + kept.append(e); + } + m_edges = kept; + for (auto it = m_linksOfFile.begin(); it != m_linksOfFile.end(); ++it) { + QVariantList &links = it.value(); + for (int i = links.size() - 1; i >= 0; --i) { + if (links[i].toMap().value("path").toString() == cleanPath) + links.removeAt(i); + } + } + dropNode(key); + // bybase-записи, указывавшие на удалённый файл + for (auto it = m_fileByBase.begin(); it != m_fileByBase.end();) { + if (it.value() == cleanPath) + it = m_fileByBase.erase(it); + else + ++it; + } + m_fileMeta.remove(cleanPath); +} + +bool VaultIndex::relinkForNewFile(const QString &newPath) +{ + Q_UNUSED(newPath) + // Новый файл мог починить чьи-то висячие ссылки — перепроверяем только их + bool any = false; + const QStringList files = m_unresolvedByFile.keys(); + for (const QString &f : files) { + if (!m_fileMeta.contains(f)) { + m_unresolvedByFile.remove(f); + continue; + } + const QString dir = QFileInfo(f).absolutePath(); + bool nowOk = false; + for (const QString &t : std::as_const(m_unresolvedByFile)[f]) { + if (!resolveLink(dir, t).isEmpty()) { + nowOk = true; + break; + } + } + if (nowOk) { + purgeOutgoing(f); + parseFile(f); + any = true; + } + } + if (any) + deduplicateEdges(); + return any; +} + +void VaultIndex::rebuild(const QString &vault) +{ + if (vault.isEmpty() || !QDir(vault).exists()) { + m_vault.clear(); + m_nodes.clear(); + m_edges.clear(); + m_nodeById.clear(); + m_tagsOfFile.clear(); + m_linksOfFile.clear(); + m_filesWithTag.clear(); + m_fileByBase.clear(); + m_unresolvedByFile.clear(); + m_fileMeta.clear(); + emit indexChanged(); + return; + } + if (m_busy) + return; + m_busy = true; + emit busyChanged(); + + m_vault = QDir::cleanPath(vault); + m_nodes.clear(); + m_edges.clear(); + m_nodeById.clear(); + m_tagsOfFile.clear(); + m_linksOfFile.clear(); + m_filesWithTag.clear(); + m_fileByBase.clear(); + m_unresolvedByFile.clear(); + m_fileMeta.clear(); + + // Проход 1: собрать все md-файлы и карту имён + QStringList files; + collectFiles(m_vault, 0, files); + for (const QString &f : files) { + const QString clean = QDir::cleanPath(f); + const QString base = QFileInfo(clean).completeBaseName(); + const QString key = base.toLower(); + if (!base.isEmpty() && !m_fileByBase.contains(key)) + m_fileByBase.insert(key, clean); + // также храним оригинал для точного поиска + if (!base.isEmpty() && !m_fileByBase.contains(base)) + m_fileByBase.insert(base, clean); + m_fileMeta.insert(clean, metaOf(clean)); + } + // Проход 2: узлы/теги/ссылки + for (const QString &f : files) + parseFile(QDir::cleanPath(f)); + deduplicateEdges(); m_busy = false; emit busyChanged(); emit indexChanged(); } +void VaultIndex::updateFile(const QString &path) +{ + if (m_vault.isEmpty()) + return; + const QString clean = QDir::cleanPath(path); + if (!shouldIndex(clean)) { + // Удаление / переименование-из / не-md: просто выкинуть из индекса + if (m_fileMeta.contains(clean)) { + dropFile(clean); + emit indexChanged(); + } + return; + } + const bool isNew = !m_fileMeta.contains(clean); + const QStringList oldTags = m_tagsOfFile.value(clean); + const QVariantList oldLinks = m_linksOfFile.value(clean); + purgeOutgoing(clean); + parseFile(clean); + deduplicateEdges(); + m_fileMeta.insert(clean, metaOf(clean)); + bool changed = isNew || m_tagsOfFile.value(clean) != oldTags + || m_linksOfFile.value(clean) != oldLinks; + if (isNew) + changed = relinkForNewFile(clean) || changed; + if (changed) + emit indexChanged(); +} + +void VaultIndex::removeFile(const QString &path) +{ + if (m_vault.isEmpty()) + return; + const QString clean = QDir::cleanPath(path); + if (!m_fileMeta.contains(clean) && !m_nodeById.contains(nodeKeyFor(clean, true))) + return; + dropFile(clean); + emit indexChanged(); +} + +void VaultIndex::syncIncremental() +{ + if (m_vault.isEmpty() || !QDir(m_vault).exists() || m_busy) + return; + // Только метаданные (без чтения содержимого) — дёшево даже на тысячах файлов + QStringList files; + collectFiles(m_vault, 0, files); + QSet seen; + seen.reserve(files.size() * 2); + bool changed = false; + for (const QString &f : files) { + const QString clean = QDir::cleanPath(f); + seen.insert(clean); + auto it = m_fileMeta.constFind(clean); + if (it == m_fileMeta.constEnd() || it.value() != metaOf(clean)) { + const QStringList oldTags = m_tagsOfFile.value(clean); + const QVariantList oldLinks = m_linksOfFile.value(clean); + const bool isNew = (it == m_fileMeta.constEnd()); + purgeOutgoing(clean); + parseFile(clean); + m_fileMeta.insert(clean, metaOf(clean)); + changed = changed || isNew || m_tagsOfFile.value(clean) != oldTags + || m_linksOfFile.value(clean) != oldLinks; + if (isNew) + changed = relinkForNewFile(clean) || changed; + } + } + for (auto it = m_fileMeta.constBegin(); it != m_fileMeta.constEnd(); ++it) { + if (!seen.contains(it.key())) { + dropFile(it.key()); + changed = true; + } + } + if (changed) { + deduplicateEdges(); + emit indexChanged(); + } +} + void VaultIndex::collectFiles(const QString &dir, int depth, QStringList &out) const { if (depth > 24) @@ -251,8 +455,10 @@ void VaultIndex::parseFile(const QString &path) m_filesWithTag[tag].append(path); } - // Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki) + // Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki). + // Неразрешённые цели запоминаем — вдруг нужный файл создадут позже. QVariantList links; + QStringList unresolved; static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]")); auto wi = wikiRe.globalMatch(text); while (wi.hasNext()) { @@ -270,6 +476,8 @@ void VaultIndex::parseFile(const QString &path) addEdge(key, nodeKeyFor(resolved, true)); ensureNode(nodeKeyFor(resolved, true), QFileInfo(resolved).fileName(), QStringLiteral("file")); + } else if (!unresolved.contains(target)) { + unresolved.append(target); } } static const QRegularExpression mdLinkRe( @@ -286,9 +494,15 @@ void VaultIndex::parseFile(const QString &path) addEdge(key, nodeKeyFor(resolved, true)); ensureNode(nodeKeyFor(resolved, true), QFileInfo(resolved).fileName(), QStringLiteral("file")); + } else if (!unresolved.contains(target)) { + unresolved.append(target); } } m_linksOfFile.insert(path, links); + if (unresolved.isEmpty()) + m_unresolvedByFile.remove(path); + else + m_unresolvedByFile.insert(path, unresolved); } QStringList VaultIndex::tagsOfFile(const QString &path) const diff --git a/src/core/VaultIndex.h b/src/core/VaultIndex.h index b374173..c35494d 100644 --- a/src/core/VaultIndex.h +++ b/src/core/VaultIndex.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -21,12 +22,22 @@ public: QVariantList nodes() const { return m_nodes; } QVariantList edges() const { return m_edges; } - int fileCount() const { return m_fileCount; } + int fileCount() const { return m_fileMeta.size(); } bool busy() const { return m_busy; } - // Полное пересканирование хранилища. + // Полное пересканирование хранилища (старт, смена хранилища, вручную, внешние изменения). Q_INVOKABLE void rebuild(const QString &vault); + // Точечно перечитать один файл (создание/сохранение/переименование-в). Дёшево. + Q_INVOKABLE void updateFile(const QString &path); + + // Убрать один файл из индекса (удаление/переименование-из). + Q_INVOKABLE void removeFile(const QString &path); + + // Сверить список файлов с диском по mtime/size и дочитать только изменённые. + // Для внешних изменений (синхронизация, другие программы). + Q_INVOKABLE void syncIncremental(); + // Теги файла / ссылки файла (список {text,path} для открытия). Q_INVOKABLE QStringList tagsOfFile(const QString &path) const; Q_INVOKABLE QVariantList linksOfFile(const QString &path) const; @@ -41,13 +52,32 @@ signals: void busyChanged(); private: + struct FileMeta { + QDateTime mtime; + qint64 size = -1; + bool operator==(const FileMeta &o) const { return mtime == o.mtime && size == o.size; } + bool operator!=(const FileMeta &o) const { return !(*this == o); } + }; + QString nodeKeyFor(const QString &path, bool isFile) const; void collectFiles(const QString &dir, int depth, QStringList &out) const; void parseFile(const QString &path); QString resolveLink(const QString &fileDir, const QString &target) const; int ensureNode(const QString &key, const QString &name, const QString &type); void addEdge(const QString &from, const QString &to); + void deduplicateEdges(); + // Файл попадает в индекс? + bool shouldIndex(const QString &cleanPath) const; + // Снять собственные данные файла (теги/исходящие рёбра/ссылки), узел и входящие — нет. + void purgeOutgoing(const QString &cleanPath); + // Полностью выкинуть файл: + входящие рёбра/ссылки, узел, bybase, мета. + void dropFile(const QString &cleanPath); + void dropNode(const QString &key); + // Перепарсить файлы, чьи неразрешённые ссылки могли починиться новым файлом. + bool relinkForNewFile(const QString &newPath); + static FileMeta metaOf(const QString &cleanPath); + QString m_vault; QVariantList m_nodes; QVariantList m_edges; QHash m_nodeById; @@ -55,6 +85,7 @@ private: QHash m_linksOfFile; QHash m_filesWithTag; QHash m_fileByBase; // имя без расширения → путь - int m_fileCount = 0; + QHash m_unresolvedByFile; // файл → неразрешённые цели ссылок + QHash m_fileMeta; // проиндексированные .md bool m_busy = false; }; \ No newline at end of file diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp index ca085d2..faf424d 100644 --- a/src/markdown/MdParser.cpp +++ b/src/markdown/MdParser.cpp @@ -959,14 +959,6 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault return blocks; } -QString MdParser::highlightedCode(const QString &code, const QString &language) const -{ - QString lang = language.trimmed(); - if (!m || !m->m_repo) return plainToRich(code); - CodeHighlighter hl(m->m_repo); - return hl.highlight(code, lang, true); -} - // Потокобезопасное ядро diff-блоков: работает с явно переданным Impl // (в главном потоке — m, в рабочем — локальный), состояние только в нём. static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd, diff --git a/src/markdown/MdParser.h b/src/markdown/MdParser.h index 1b59cce..11ebb19 100644 --- a/src/markdown/MdParser.h +++ b/src/markdown/MdParser.h @@ -49,7 +49,6 @@ public: Q_INVOKABLE void copyToClipboard(const QString &text) const; // Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать. Q_INVOKABLE bool copyCodeBlock(const QString &id) const; - Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const; signals: void blocksReady(int requestId, const QVariantList &blocks); diff --git a/tests/documentmanager_test.cpp b/tests/documentmanager_test.cpp index 4bcd774..24ce92e 100644 --- a/tests/documentmanager_test.cpp +++ b/tests/documentmanager_test.cpp @@ -129,6 +129,26 @@ int main(int argc, char *argv[]) || dm.isTextForEditor("/x/a.pdf")) return fail("isTextForEditor"); + // --- autosaveEnabled (ручной режим настроек) --- + if (!dm.autosaveEnabled()) + return fail("autosave enabled by default"); + { + QTimer *t = dm.findChild(); + if (!t || !t->isActive()) + return fail("autosave timer active"); + } + dm.setAutosaveEnabled(false); + if (dm.autosaveEnabled()) + return fail("autosave disabled"); + { + QTimer *t = dm.findChild(); + if (!t || t->isActive()) + return fail("autosave timer stopped"); + } + dm.setAutosaveEnabled(true); + if (!dm.autosaveEnabled()) + return fail("autosave re-enabled"); + std::printf("DOCUMENT MANAGER TEST OK\n"); return 0; } \ No newline at end of file diff --git a/tests/mdparser_test.cpp b/tests/mdparser_test.cpp index 9a42797..2ed80e5 100644 --- a/tests/mdparser_test.cpp +++ b/tests/mdparser_test.cpp @@ -138,11 +138,11 @@ int main(int argc, char *argv[]) // Подсветка: переносы строк — явные
, иначе RichText склеит в одну строку { - QString hl = p.highlightedCode("int a = 1;\nint b = 2;\n", "cpp"); + auto b = p.parseBlocks("```cpp\nint a = 1;\nint b = 2;\n```\n", vault, fileDir); + if (b.size() != 1) return fail("br блок"); + const QString hl = b[0].toMap().value("highlightedText").toString(); if (!hl.contains("
")) return fail("highlighted без
", hl.left(200)); if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200)); - QString plain = p.highlightedCode("a\nb", "неизвестный-язык-xyz"); - if (!plain.contains("
")) return fail("plain highlight без
", plain); } // parseBlocksWithDiff: версия old vs текущее new diff --git a/tests/settings_test.cpp b/tests/settings_test.cpp index 1c2f6b8..91ee6d0 100644 --- a/tests/settings_test.cpp +++ b/tests/settings_test.cpp @@ -124,6 +124,28 @@ int main(int argc, char *argv[]) if (after.contains(vault + "|" + "old.md")) return fail("old key not migrated"); } + // autosave presets + if (s.autosavePreset() != "balanced") return fail("default preset"); + if (s.autosaveDiskMs() != 5000) return fail("balanced disk"); + if (s.autocommitIdleMs() != 15000) return fail("balanced idle"); + if (!s.autosaveEnabled() || !s.autocommitEnabled()) return fail("balanced enabled"); + s.setAutosavePreset("safe"); + if (s.autosavePreset() != "safe") return fail("safe preset"); + if (s.autosaveDiskMs() != 2000 || s.autocommitIdleMs() != 5000) return fail("safe values"); + s.setAutosavePreset("eco"); + if (s.autosaveDiskMs() != 10000 || s.autocommitIdleMs() != 60000) return fail("eco values"); + s.setAutosavePreset("manual"); + if (s.autosaveEnabled() || s.autocommitEnabled()) return fail("manual disabled"); + if (s.autosaveDiskMs() != 0 || s.autocommitIdleMs() != 0) return fail("manual zeros"); + s.setAutosavePreset(" wat? "); + if (s.autosavePreset() != "balanced") return fail("invalid preset fallback"); + s.setAutosavePreset("eco"); + { + SettingsStore s2; + if (s2.autosavePreset() != "eco") return fail("preset persistence"); + if (s2.autosaveDiskMs() != 10000) return fail("preset values persistence"); + } + std::printf("SETTINGS TEST OK\n"); return 0; } diff --git a/tests/vaultindex_test.cpp b/tests/vaultindex_test.cpp index 57d1ff7..e94bdfe 100644 --- a/tests/vaultindex_test.cpp +++ b/tests/vaultindex_test.cpp @@ -87,6 +87,75 @@ int main(int argc, char *argv[]) if (files2.size() != 2) return fail("filesWithTag(тег2)", QString::number(files2.size())); + // --- инкремент: updateFile меняет только один файл (убираем теги из a.md) --- + put("a.md", "# Заголовок\n\nСсылка на [[b]].\n"); + idx.updateFile(a); + if (idx.fileCount() != 3) + return fail("updateFile fileCount", QString::number(idx.fileCount())); + // a.md теперь без тегов — тег1 остался только... нигде: filesWithTag пуст + if (!idx.filesWithTag("тег1").isEmpty()) + return fail("updateFile снял тег1", idx.filesWithTag("тег1").join(",")); + if (idx.tagsOfFile(a).size() != 0) + return fail("updateFile tagsOfFile", idx.tagsOfFile(a).join(",")); + // b.md не трогали — его теги на месте + if (!idx.tagsOfFile(b).contains("тег2")) + return fail("updateFile не тронул b"); + + // --- инкремент: новый файл чинит висячую ссылку (relink) --- + put("d.md", "Ссылка на [[future]].\n"); + const QString d = dir.path() + "/d.md"; + idx.updateFile(d); + if (idx.fileCount() != 4) + return fail("updateFile new fileCount", QString::number(idx.fileCount())); + if (!idx.linksOfFile(d).isEmpty()) + return fail("висячая ссылка должна быть пустой"); + put("sub/future.md", "# Future\n"); + const QString future = dir.path() + "/sub/future.md"; + idx.updateFile(future); + { + bool foundFuture = false; + for (const auto &l : idx.linksOfFile(d)) + if (l.toMap().value("path").toString() == future) + foundFuture = true; + if (!foundFuture) + return fail("relink не починил [[future]]"); + } + + // --- инкремент: removeFile чистит теги/ссылки/узел --- + // (в реальности вызывается потому что файл удалён — удаляем и с диска) + QFile::remove(future); + idx.removeFile(future); + if (idx.fileCount() != 4) + return fail("removeFile fileCount", QString::number(idx.fileCount())); + { + bool foundFuture = false; + for (const auto &l : idx.linksOfFile(d)) + if (l.toMap().value("path").toString() == future) + foundFuture = true; + if (foundFuture) + return fail("removeFile не убрал входящую ссылку"); + } + // removeFile несуществующего — тихо, без падения + idx.removeFile(dir.path() + "/нет-такого.md"); + idx.updateFile(dir.path() + "/нет-такого.md"); + + // --- инкремент: syncIncremental догоняет внешние изменения --- + put("ext.md", "Внешний #внешний\n"); + const QString ext = dir.path() + "/ext.md"; + if (idx.fileCount() != 4) + return fail("до sync файл не виден", QString::number(idx.fileCount())); + idx.syncIncremental(); + if (idx.fileCount() != 5) + return fail("sync подхватил файл", QString::number(idx.fileCount())); + if (!idx.tagsOfFile(ext).contains("внешний")) + return fail("sync теги"); + QFile::remove(ext); + idx.syncIncremental(); + if (idx.fileCount() != 4) + return fail("sync убрал файл", QString::number(idx.fileCount())); + if (idx.tagsOfFile(ext).size() != 0) + return fail("sync теги удалены"); + std::printf("VAULTINDEX TEST OK\n"); return 0; } \ No newline at end of file