From d712a20691c57c546d5bbff319587426920026ad Mon Sep 17 00:00:00 2001 From: PCCV-Tours Date: Mon, 31 Aug 2026 17:48:05 +0200 Subject: [PATCH] Detect external file modifications immediately, matching Notepad++ behavior - Add centralized QFileSystemWatcher in EditorManager to detect external file changes without waiting for tab switch or window focus - Reliably trigger the check on window (re)activation via QEvent::ActivationChange (covers X11/Cinnamon gap in existing applicationStateChanged mechanism) - Warn the user before discarding unsaved changes when reloading a file modified externally --- src/EditorManager.cpp | 78 ++++++++++++++++++++++++++++++++++++++ src/EditorManager.h | 27 +++++++++++++ src/dialogs/MainWindow.cpp | 42 +++++++++++++++++++- src/dialogs/MainWindow.h | 1 + 4 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/EditorManager.cpp b/src/EditorManager.cpp index 2f04f674c..adce767b2 100644 --- a/src/EditorManager.cpp +++ b/src/EditorManager.cpp @@ -17,6 +17,7 @@ */ #include +#include #include "ApplicationSettings.h" @@ -46,10 +47,42 @@ const int MARK_HIDELINESUNDERLINE = 21; EditorManager::EditorManager(ApplicationSettings *settings, QObject *parent) : QObject(parent), settings(settings) { + + fileWatcher = new QFileSystemWatcher(this); + + connect(fileWatcher, &QFileSystemWatcher::fileChanged, this, [=, this](const QString &path) { + // Debounce: a single external write commonly fires this signal 2+ + // times in a row. Only arm one timer per path; further signals + // received before it fires are simply dropped. + if (pendingFileChanges.contains(path)) { + return; + } + + pendingFileChanges.insert(path); + + QTimer::singleShot(250, this, [=, this]() { + pendingFileChanges.remove(path); + processExternalFileChange(path); + }); + }); + connect(this, &EditorManager::editorCreated, this, [=, this](ScintillaNext *editor) { connect(editor, &ScintillaNext::closed, this, [=, this]() { emit editorClosed(editor); }); + + connect(editor, &ScintillaNext::closed, this, [=, this]() { + unwatchEditorFile(editor); + }); + + // Covers "Save As" and renaming a "New" buffer into an actual file, + // in addition to a plain rename - fileInfo is already up to date + // by the time this signal fires. + connect(editor, &ScintillaNext::renamed, this, [=, this]() { + watchEditorFile(editor); + }); + + watchEditorFile(editor); }); connect(settings, &ApplicationSettings::showWrapSymbolChanged, this, [=, this](bool b) { @@ -428,6 +461,51 @@ QList > EditorManager::getEditors() return editors; } +void EditorManager::processExternalFileChange(const QString &path) +{ + for (ScintillaNext *editor : qAsConst(editors)) { + if (editor && editor->isFile() && editor->getFilePath() == path) { + // Some editors/tools save by replacing the file (rename-over-write), + // which makes the OS-level watch silently drop the path. Re-arm it + // whenever we can, so subsequent external edits keep being detected. + if (QFileInfo::exists(path) && !fileWatcher->files().contains(path)) { + fileWatcher->addPath(path); + } + + emit editorFileChangedOnDisk(editor); + } + } +} + +void EditorManager::watchEditorFile(ScintillaNext *editor) +{ + const QString oldPath = watchedPaths.value(editor); + const QString newPath = editor->isFile() ? editor->getFilePath() : QString(); + + if (oldPath == newPath) { + return; + } + + if (!oldPath.isEmpty()) { + fileWatcher->removePath(oldPath); + watchedPaths.remove(editor); + } + + if (!newPath.isEmpty()) { + fileWatcher->addPath(newPath); + watchedPaths.insert(editor, newPath); + } +} + +void EditorManager::unwatchEditorFile(ScintillaNext *editor) +{ + const QString path = watchedPaths.take(editor); + + if (!path.isEmpty()) { + fileWatcher->removePath(path); + } +} + int EditorManager::detectEOLMode(ScintillaNext *editor) const { qInfo(Q_FUNC_INFO); diff --git a/src/EditorManager.h b/src/EditorManager.h index 0aebb1ebb..d9a66d169 100644 --- a/src/EditorManager.h +++ b/src/EditorManager.h @@ -22,6 +22,9 @@ #include #include +#include +#include +#include class ApplicationSettings; @@ -45,14 +48,38 @@ class EditorManager : public QObject void editorCreated(ScintillaNext *editor); void editorClosed(ScintillaNext *editor); + // Emitted asynchronously whenever the OS reports that an open file was + // touched outside the application (write, permissions, delete, etc). + // Does NOT get emitted for the application's own saves, since the + // in-memory timestamp is refreshed synchronously before this signal + // could ever be observed. + void editorFileChangedOnDisk(ScintillaNext *editor); + private: void setupEditor(ScintillaNext *editor); void purgeOldEditorPointers(); QList> getEditors(); int detectEOLMode(ScintillaNext *editor) const; + void watchEditorFile(ScintillaNext *editor); + void unwatchEditorFile(ScintillaNext *editor); + void processExternalFileChange(const QString &path); + QList> editors; ApplicationSettings *settings; + + QFileSystemWatcher *fileWatcher; + + // Tracks the path currently registered with fileWatcher for each editor, + // so a rename/"Save As" can drop the old path instead of leaking an + // orphaned watch on it. + QHash watchedPaths; + + // A single external write commonly triggers 2+ fileChanged signals in a + // row (this is a well-known QFileSystemWatcher/inotify quirk, not a bug + // in this class). Paths in this set already have a debounce timer + // pending, so further signals for them are ignored until it fires. + QSet pendingFileChanges; }; #endif // EDITORMANAGER_H diff --git a/src/dialogs/MainWindow.cpp b/src/dialogs/MainWindow.cpp index d689984a0..598310bbf 100644 --- a/src/dialogs/MainWindow.cpp +++ b/src/dialogs/MainWindow.cpp @@ -122,6 +122,27 @@ MainWindow::MainWindow(NotepadNextApplication *app) : connect(dockedEditor, &DockedEditor::contextMenuRequestedForEditor, this, &MainWindow::tabBarRightClicked); connect(dockedEditor, &DockedEditor::titleBarDoubleClicked, this, &MainWindow::newFile); + // React immediately (instead of only on tab-switch/window-focus) when an + // open file is changed by another program - but only if NotepadNext is + // actually the foreground window and the affected editor is the one + // currently visible, matching Notepad++'s behavior. Otherwise, do + // nothing here: the existing activateEditor() (tab switch) and + // focusIn() (window regains focus) code paths already re-check the + // file's timestamp and will surface the dialog at the right time. + connect(app->getEditorManager(), &EditorManager::editorFileChangedOnDisk, this, [=, this](ScintillaNext *editor) { + if (!isActiveWindow()) { + return; + } + + if (editor != currentEditor()) { + return; + } + + if (checkFileForModification(editor)) { + updateGui(currentEditor()); + } + }); + // Set up the menus connect(ui->actionNew, &QAction::triggered, this, &MainWindow::newFile); connect(ui->actionOpen, &QAction::triggered, this, &MainWindow::openFileDialog); @@ -1952,8 +1973,15 @@ bool MainWindow::checkFileForModification(ScintillaNext *editor) else if (state == ScintillaNext::Modified) { qInfo("ScintillaNext::Modified"); const QString filePath = editor->getFilePath(); - auto reply = QMessageBox::question(this, tr("Reload File"), tr("%1 has been modified by another program. Do you want to reload it?").arg(filePath)); + QString message = tr("%1 has been modified by another program. Do you want to reload it?").arg(filePath); + if (!editor->isSavedToDisk()) { + message += tr("

Warning: you have unsaved changes in this tab. " + "Reloading will discard them permanently."); + } + + auto reply = QMessageBox::question(this, tr("Reload File"), message); + if (reply == QMessageBox::Yes) { editor->reload(); } @@ -2103,6 +2131,18 @@ void MainWindow::focusIn() } } +void MainWindow::changeEvent(QEvent *event) +{ + QMainWindow::changeEvent(event); + + if (event->type() == QEvent::ActivationChange) { + qInfo() << "ActivationChange reçu, isActiveWindow() =" << isActiveWindow(); + if (isActiveWindow()) { + focusIn(); + } + } +} + void MainWindow::addEditor(ScintillaNext *editor) { qInfo(Q_FUNC_INFO); diff --git a/src/dialogs/MainWindow.h b/src/dialogs/MainWindow.h index b79b0b836..a9e4c633c 100644 --- a/src/dialogs/MainWindow.h +++ b/src/dialogs/MainWindow.h @@ -140,6 +140,7 @@ public slots: void closeEvent(QCloseEvent *event) override; void dragEnterEvent(QDragEnterEvent *event) override; void dropEvent(QDropEvent *event) override; + void changeEvent(QEvent *event) override; private slots: void tabBarRightClicked(ScintillaNext *editor);