Инкрементальный индекс, пресеты автосейва, диалог Настройки

- VaultIndex: updateFile/removeFile/syncIncremental (mtime+size), полный рескан только на старте/смене/внешних изменениях; relink висячих [[ссылок]] при создании файла; FileStore.externalChanged для внешних правок
- QML: сейвы и правки идут точечно, дерево — через syncIncremental
- Настройки: пресеты safe/balanced/eco/manual (диск 2/5/10с/выкл, коммит после паузы 5/15/60с/вручную), диалог с +/-, шкалами и подсказкой; коммит только в простое, в manual — по Ctrl+S
- Удалены highlightedCode, словный merge, fileDiffText/Html, 2-arg fileHistory; renderInner без побочек
- Тесты: инкремент индекса, пресеты, autosaveEnabled, ~~~-блоки
This commit is contained in:
2026-09-06 23:02:05 +03:00
parent 1f140d3433
commit 417d0273b7
20 changed files with 802 additions and 80 deletions
+1
View File
@@ -131,6 +131,7 @@ qt_add_qml_module(flora
qml/dialogs/ConfirmDialog.qml qml/dialogs/ConfirmDialog.qml
qml/dialogs/TableEditor.qml qml/dialogs/TableEditor.qml
qml/dialogs/HistoryDialog.qml qml/dialogs/HistoryDialog.qml
qml/dialogs/SettingsDialog.qml
) )
# Theme как синглтон # Theme как синглтон
+19
View File
@@ -49,6 +49,15 @@ ApplicationWindow {
// статус можно показать в UI, пока просто игнорируем // статус можно показать в UI, пока просто игнорируем
} }
function applyAutosavePreset() {
if (settingsStore.autosaveEnabled) {
documentManager.setAutosaveIntervalMs(settingsStore.autosaveDiskMs)
documentManager.setAutosaveEnabled(true)
} else {
documentManager.setAutosaveEnabled(false)
}
}
Component.onCompleted: { Component.onCompleted: {
if (windowVault.length > 0) { if (windowVault.length > 0) {
fileStore.openVault(windowVault) fileStore.openVault(windowVault)
@@ -58,6 +67,11 @@ ApplicationWindow {
if (vaultManager.vaults.length === 0) { if (vaultManager.vaults.length === 0) {
welcomeWizard.open() welcomeWizard.open()
} }
applyAutosavePreset()
}
Connections {
target: settingsStore
function onAutosavePresetChanged() { applyAutosavePreset() }
} }
menuBar: MenuBar { menuBar: MenuBar {
@@ -72,6 +86,7 @@ ApplicationWindow {
Action { text: qsTr("В PDF…"); enabled: false } Action { text: qsTr("В PDF…"); enabled: false }
} }
MenuSeparator { } MenuSeparator { }
Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() }
Action { text: qsTr("Выход"); onTriggered: window.close() } Action { text: qsTr("Выход"); onTriggered: window.close() }
} }
Menu { Menu {
@@ -164,6 +179,10 @@ ApplicationWindow {
} }
} }
SettingsDialog {
id: settingsDialog
}
Popup { Popup {
id: vaultSwitcher id: vaultSwitcher
x: 12 x: 12
+224
View File
@@ -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 }
}
}
}
}
+25 -13
View File
@@ -782,23 +782,26 @@ Item {
} }
refreshGitStatus() refreshGitStatus()
} }
function armAutoCommit(path) {
// Коммит только после паузы в печати (idle), а не на каждое сохранение
if (!settingsStore.autocommitEnabled || !fileStore.ready || !path || path.length === 0)
return
autoCommitDebounce.filePath = path
autoCommitDebounce.restart()
}
Connections { Connections {
target: documentManager target: documentManager
function onActiveIndexChanged() { root.loadContent() } 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 onOpenTabsChanged() { root.loadContent() }
function onDirtyFlagChanged() { refreshGitStatus() } function onDirtyFlagChanged() { refreshGitStatus() }
function onDocumentSaved(idx) { function onDocumentSaved(idx) {
refreshGitStatus() refreshGitStatus()
// Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю if (idx >= 0) root.armAutoCommit(documentManager.pathAt(idx))
if (fileStore.ready && idx >= 0) {
const p = documentManager.pathAt(idx)
if (p && p.length>0) {
// Небольшая задержка чтобы не спамить на каждый автосейв
autoCommitDebounce.filePath = p
autoCommitDebounce.restart()
}
}
} }
} }
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } } Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
@@ -806,9 +809,9 @@ Item {
Timer { Timer {
id: autoCommitDebounce id: autoCommitDebounce
property string filePath: "" property string filePath: ""
interval: 2000 interval: settingsStore.autocommitIdleMs
onTriggered: { onTriggered: {
if (fileStore.ready && filePath.length>0) { if (settingsStore.autocommitEnabled && fileStore.ready && filePath.length>0) {
gitManager.autoCommit(fileStore.vault, filePath) gitManager.autoCommit(fileStore.vault, filePath)
refreshGitStatus() refreshGitStatus()
} }
@@ -996,7 +999,16 @@ Item {
TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } } 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+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
Shortcut { sequence: "Ctrl+H"; onActivated: { Shortcut { sequence: "Ctrl+H"; onActivated: {
const p = documentManager.pathAt(documentManager.activeIndex) const p = documentManager.pathAt(documentManager.activeIndex)
+9 -2
View File
@@ -151,6 +151,7 @@ Item {
if (drop.source && drop.source.dragPath && fileStore.ready) { if (drop.source && drop.source.dragPath && fileStore.ready) {
const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault) const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault)
if (err.length > 0) showStatus(err, false) if (err.length > 0) showStatus(err, false)
else vaultIndex.syncIncremental()
} }
} }
} }
@@ -282,6 +283,7 @@ Item {
if (!rowRect.isDir || !drop.source || !drop.source.dragPath) return if (!rowRect.isDir || !drop.source || !drop.source.dragPath) return
const err = fileStore.moveItem(drop.source.dragPath, rowRect.path) const err = fileStore.moveItem(drop.source.dragPath, rowRect.path)
if (err.length > 0) root.showStatus(err, false) 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) if (pendingCreate.kind==="file") err=fileStore.createFile(createIn,name)
else if (pendingCreate.kind==="folder") err=fileStore.createFolder(createIn,name) else if (pendingCreate.kind==="folder") err=fileStore.createFolder(createIn,name)
else if (pendingCreate.kind==="rename") err=fileStore.renameItem(renameTarget.path,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="" 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() } } }
} }
+18 -3
View File
@@ -26,16 +26,31 @@ Item {
target: fileStore target: fileStore
function onVaultChanged() { 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) } function onReadyChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
// Внешнее изменение (синхронизация/другая программа): досинхронизировать индекс
// по mtime — дёшево, без полного рескана
function onExternalChanged() { if (fileStore.ready) vaultIndex.syncIncremental() }
} }
Connections { Connections {
target: documentManager target: documentManager
function onActiveIndexChanged() { root.refreshLinksTags() } function onActiveIndexChanged() { root.refreshLinksTags() }
function onOpenTabsChanged() { root.refreshLinksTags() } function onOpenTabsChanged() { root.refreshLinksTags() }
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); rebuildDebounce.restart() } } function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } }
function onDocumentSaved() { rebuildDebounce.restart() } function onDocumentSaved(idx, path) {
if (fileStore.ready && path && path.length > 0) vaultIndex.updateFile(path)
}
} }
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } } 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 { SplitView {
+15
View File
@@ -63,6 +63,21 @@ void DocumentManager::setSideBySide(bool on)
emit sideBySideChanged(); 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; } int DocumentManager::autosaveIntervalMs() const { return m_autosaveMs; }
void DocumentManager::setAutosaveIntervalMs(int ms) void DocumentManager::setAutosaveIntervalMs(int ms)
{ {
+6
View File
@@ -17,6 +17,8 @@ class DocumentManager : public QObject
Q_PROPERTY(bool sideBySide READ sideBySide WRITE setSideBySide NOTIFY sideBySideChanged) Q_PROPERTY(bool sideBySide READ sideBySide WRITE setSideBySide NOTIFY sideBySideChanged)
Q_PROPERTY(int autosaveIntervalMs READ autosaveIntervalMs WRITE setAutosaveIntervalMs Q_PROPERTY(int autosaveIntervalMs READ autosaveIntervalMs WRITE setAutosaveIntervalMs
NOTIFY autosaveIntervalChanged) NOTIFY autosaveIntervalChanged)
Q_PROPERTY(bool autosaveEnabled READ autosaveEnabled WRITE setAutosaveEnabled
NOTIFY autosaveEnabledChanged)
Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged) Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged)
public: public:
@@ -31,6 +33,8 @@ public:
Q_INVOKABLE void setSideBySide(bool on); Q_INVOKABLE void setSideBySide(bool on);
int autosaveIntervalMs() const; int autosaveIntervalMs() const;
Q_INVOKABLE void setAutosaveIntervalMs(int ms); Q_INVOKABLE void setAutosaveIntervalMs(int ms);
bool autosaveEnabled() const;
Q_INVOKABLE void setAutosaveEnabled(bool on);
QString lastStatus() const; QString lastStatus() const;
// Открывает документ (при необходимости создаётся вкладка). Возвращает индекс. // Открывает документ (при необходимости создаётся вкладка). Возвращает индекс.
@@ -60,6 +64,7 @@ signals:
void secondaryIndexChanged(); void secondaryIndexChanged();
void sideBySideChanged(); void sideBySideChanged();
void autosaveIntervalChanged(); void autosaveIntervalChanged();
void autosaveEnabledChanged();
void lastStatusChanged(); void lastStatusChanged();
// Уведомляет вкладку о смене признака «изменено». // Уведомляет вкладку о смене признака «изменено».
@@ -89,6 +94,7 @@ public:
int m_secondary = -1; int m_secondary = -1;
bool m_sideBySide = false; bool m_sideBySide = false;
int m_autosaveMs = 5000; int m_autosaveMs = 5000;
bool m_autosaveEnabled = true;
QString m_lastStatus; QString m_lastStatus;
QTimer *m_autosave = nullptr; QTimer *m_autosave = nullptr;
}; };
+7 -1
View File
@@ -12,7 +12,7 @@ FileStore::FileStore(QObject *parent)
{ {
m_debounce.setSingleShot(true); m_debounce.setSingleShot(true);
m_debounce.setInterval(300); 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, connect(&m_watcher, &QFileSystemWatcher::directoryChanged,
this, &FileStore::scheduleRebuild); this, &FileStore::scheduleRebuild);
} }
@@ -223,6 +223,12 @@ void FileStore::scheduleRebuild()
m_debounce.start(); m_debounce.start();
} }
void FileStore::onExternalTimeout()
{
rebuild();
emit externalChanged();
}
void FileStore::toggleExpand(int row) void FileStore::toggleExpand(int row)
{ {
if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir) if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir)
+4
View File
@@ -65,6 +65,9 @@ signals:
void readyChanged(); void readyChanged();
void fileActivated(const QString &path); void fileActivated(const QString &path);
void statusMessage(const QString &message); void statusMessage(const QString &message);
// Сработало внешнее изменение (синхронизация/другая программа) — дерево уже
// перестроено; собственные операции этот сигнал не вызывают.
void externalChanged();
private: private:
struct Row { struct Row {
@@ -78,6 +81,7 @@ private:
}; };
void rebuild(); void rebuild();
void onExternalTimeout();
void collectDir(const QString &dir, int depth, QVector<Row> &out) const; void collectDir(const QString &dir, int depth, QVector<Row> &out) const;
bool isIgnored(const QString &path) const; bool isIgnored(const QString &path) const;
int rowOf(const QString &path) const; int rowOf(const QString &path) const;
+51 -1
View File
@@ -14,6 +14,7 @@ constexpr auto kKeyTheme = "ui/theme";
constexpr auto kKeyLeftW = "ui/leftPanelWidth"; constexpr auto kKeyLeftW = "ui/leftPanelWidth";
constexpr auto kKeyRightW = "ui/rightPanelWidth"; constexpr auto kKeyRightW = "ui/rightPanelWidth";
constexpr auto kKeyViewModes = "ui/viewModes"; constexpr auto kKeyViewModes = "ui/viewModes";
constexpr auto kKeyAutosavePreset = "autosave/preset";
QString encodeKey(const QString &s) { QString encodeKey(const QString &s) {
// Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini // Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini
@@ -276,4 +277,53 @@ void SettingsStore::setViewModeForFile(const QString &vault, const QString &file
root.insert(encKey, cleanMode); root.insert(encKey, cleanMode);
s.setValue(kKeyViewModes, root); s.setValue(kKeyViewModes, root);
s.sync(); s.sync();
} }
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";
}
+16
View File
@@ -17,6 +17,14 @@ class SettingsStore : public QObject
Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged) Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
Q_PROPERTY(int leftPanelWidth READ leftPanelWidth WRITE setLeftPanelWidth NOTIFY leftPanelWidthChanged) Q_PROPERTY(int leftPanelWidth READ leftPanelWidth WRITE setLeftPanelWidth NOTIFY leftPanelWidthChanged)
Q_PROPERTY(int rightPanelWidth READ rightPanelWidth WRITE setRightPanelWidth NOTIFY rightPanelWidthChanged) 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: public:
explicit SettingsStore(QObject *parent = nullptr); explicit SettingsStore(QObject *parent = nullptr);
@@ -43,6 +51,13 @@ public:
int rightPanelWidth() const; int rightPanelWidth() const;
void setRightPanelWidth(int w); 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 QString viewModeForFile(const QString &vault, const QString &filePath) const;
Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode); Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode);
@@ -53,6 +68,7 @@ signals:
void themeChanged(); void themeChanged();
void leftPanelWidthChanged(); void leftPanelWidthChanged();
void rightPanelWidthChanged(); void rightPanelWidthChanged();
void autosavePresetChanged();
private: private:
QVariantList m_providers; QVariantList m_providers;
+259 -45
View File
@@ -140,53 +140,11 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c
return QString(); 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<QString> seen; QSet<QString> seen;
QVariantList uniq; QVariantList uniq;
uniq.reserve(m_edges.size());
for (const auto &e : std::as_const(m_edges)) { for (const auto &e : std::as_const(m_edges)) {
const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString(); const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString();
if (!seen.contains(k)) { if (!seen.contains(k)) {
@@ -195,12 +153,258 @@ void VaultIndex::rebuild(const QString &vault)
} }
} }
m_edges = uniq; 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; m_busy = false;
emit busyChanged(); emit busyChanged();
emit indexChanged(); 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<QString> 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 void VaultIndex::collectFiles(const QString &dir, int depth, QStringList &out) const
{ {
if (depth > 24) if (depth > 24)
@@ -251,8 +455,10 @@ void VaultIndex::parseFile(const QString &path)
m_filesWithTag[tag].append(path); m_filesWithTag[tag].append(path);
} }
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki) // Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki).
// Неразрешённые цели запоминаем — вдруг нужный файл создадут позже.
QVariantList links; QVariantList links;
QStringList unresolved;
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]")); static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
auto wi = wikiRe.globalMatch(text); auto wi = wikiRe.globalMatch(text);
while (wi.hasNext()) { while (wi.hasNext()) {
@@ -270,6 +476,8 @@ void VaultIndex::parseFile(const QString &path)
addEdge(key, nodeKeyFor(resolved, true)); addEdge(key, nodeKeyFor(resolved, true));
ensureNode(nodeKeyFor(resolved, true), ensureNode(nodeKeyFor(resolved, true),
QFileInfo(resolved).fileName(), QStringLiteral("file")); QFileInfo(resolved).fileName(), QStringLiteral("file"));
} else if (!unresolved.contains(target)) {
unresolved.append(target);
} }
} }
static const QRegularExpression mdLinkRe( static const QRegularExpression mdLinkRe(
@@ -286,9 +494,15 @@ void VaultIndex::parseFile(const QString &path)
addEdge(key, nodeKeyFor(resolved, true)); addEdge(key, nodeKeyFor(resolved, true));
ensureNode(nodeKeyFor(resolved, true), ensureNode(nodeKeyFor(resolved, true),
QFileInfo(resolved).fileName(), QStringLiteral("file")); QFileInfo(resolved).fileName(), QStringLiteral("file"));
} else if (!unresolved.contains(target)) {
unresolved.append(target);
} }
} }
m_linksOfFile.insert(path, links); 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 QStringList VaultIndex::tagsOfFile(const QString &path) const
+34 -3
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <QDateTime>
#include <QObject> #include <QObject>
#include <QVariantList> #include <QVariantList>
#include <QHash> #include <QHash>
@@ -21,12 +22,22 @@ public:
QVariantList nodes() const { return m_nodes; } QVariantList nodes() const { return m_nodes; }
QVariantList edges() const { return m_edges; } 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; } bool busy() const { return m_busy; }
// Полное пересканирование хранилища. // Полное пересканирование хранилища (старт, смена хранилища, вручную, внешние изменения).
Q_INVOKABLE void rebuild(const QString &vault); 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} для открытия). // Теги файла / ссылки файла (список {text,path} для открытия).
Q_INVOKABLE QStringList tagsOfFile(const QString &path) const; Q_INVOKABLE QStringList tagsOfFile(const QString &path) const;
Q_INVOKABLE QVariantList linksOfFile(const QString &path) const; Q_INVOKABLE QVariantList linksOfFile(const QString &path) const;
@@ -41,13 +52,32 @@ signals:
void busyChanged(); void busyChanged();
private: 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; QString nodeKeyFor(const QString &path, bool isFile) const;
void collectFiles(const QString &dir, int depth, QStringList &out) const; void collectFiles(const QString &dir, int depth, QStringList &out) const;
void parseFile(const QString &path); void parseFile(const QString &path);
QString resolveLink(const QString &fileDir, const QString &target) const; QString resolveLink(const QString &fileDir, const QString &target) const;
int ensureNode(const QString &key, const QString &name, const QString &type); int ensureNode(const QString &key, const QString &name, const QString &type);
void addEdge(const QString &from, const QString &to); 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_nodes;
QVariantList m_edges; QVariantList m_edges;
QHash<QString, int> m_nodeById; QHash<QString, int> m_nodeById;
@@ -55,6 +85,7 @@ private:
QHash<QString, QVariantList> m_linksOfFile; QHash<QString, QVariantList> m_linksOfFile;
QHash<QString, QStringList> m_filesWithTag; QHash<QString, QStringList> m_filesWithTag;
QHash<QString, QString> m_fileByBase; // имя без расширения → путь QHash<QString, QString> m_fileByBase; // имя без расширения → путь
int m_fileCount = 0; QHash<QString, QStringList> m_unresolvedByFile; // файл → неразрешённые цели ссылок
QHash<QString, FileMeta> m_fileMeta; // проиндексированные .md
bool m_busy = false; bool m_busy = false;
}; };
-8
View File
@@ -959,14 +959,6 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
return blocks; 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 // Потокобезопасное ядро diff-блоков: работает с явно переданным Impl
// (в главном потоке — m, в рабочем — локальный), состояние только в нём. // (в главном потоке — m, в рабочем — локальный), состояние только в нём.
static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd, static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd,
-1
View File
@@ -49,7 +49,6 @@ public:
Q_INVOKABLE void copyToClipboard(const QString &text) const; Q_INVOKABLE void copyToClipboard(const QString &text) const;
// Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать. // Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать.
Q_INVOKABLE bool copyCodeBlock(const QString &id) const; Q_INVOKABLE bool copyCodeBlock(const QString &id) const;
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
signals: signals:
void blocksReady(int requestId, const QVariantList &blocks); void blocksReady(int requestId, const QVariantList &blocks);
+20
View File
@@ -129,6 +129,26 @@ int main(int argc, char *argv[])
|| dm.isTextForEditor("/x/a.pdf")) || dm.isTextForEditor("/x/a.pdf"))
return fail("isTextForEditor"); return fail("isTextForEditor");
// --- autosaveEnabled (ручной режим настроек) ---
if (!dm.autosaveEnabled())
return fail("autosave enabled by default");
{
QTimer *t = dm.findChild<QTimer*>();
if (!t || !t->isActive())
return fail("autosave timer active");
}
dm.setAutosaveEnabled(false);
if (dm.autosaveEnabled())
return fail("autosave disabled");
{
QTimer *t = dm.findChild<QTimer*>();
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"); std::printf("DOCUMENT MANAGER TEST OK\n");
return 0; return 0;
} }
+3 -3
View File
@@ -138,11 +138,11 @@ int main(int argc, char *argv[])
// Подсветка: переносы строк — явные <br/>, иначе RichText склеит в одну строку // Подсветка: переносы строк — явные <br/>, иначе 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("<br/>")) return fail("highlighted без <br/>", hl.left(200)); if (!hl.contains("<br/>")) return fail("highlighted без <br/>", hl.left(200));
if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200)); if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200));
QString plain = p.highlightedCode("a\nb", "неизвестный-язык-xyz");
if (!plain.contains("<br/>")) return fail("plain highlight без <br/>", plain);
} }
// parseBlocksWithDiff: версия old vs текущее new // parseBlocksWithDiff: версия old vs текущее new
+22
View File
@@ -124,6 +124,28 @@ int main(int argc, char *argv[])
if (after.contains(vault + "|" + "old.md")) return fail("old key not migrated"); 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"); std::printf("SETTINGS TEST OK\n");
return 0; return 0;
} }
+69
View File
@@ -87,6 +87,75 @@ int main(int argc, char *argv[])
if (files2.size() != 2) if (files2.size() != 2)
return fail("filesWithTag(тег2)", QString::number(files2.size())); 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"); std::printf("VAULTINDEX TEST OK\n");
return 0; return 0;
} }