From c1406af533a3610fb40875448d1bf31664fe4542 Mon Sep 17 00:00:00 2001 From: Thomas Schouten <15669080+PHPirates@users.noreply.github.com> Date: Thu, 28 May 2026 08:19:52 +0200 Subject: [PATCH 1/3] Refresh when focus is regained --- .../pdf/viewer/ui/editor/PdfFileEditor.kt | 82 +++++++++++++++++-- .../editor/view/PdfJcefPreviewController.kt | 11 ++- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt index 89c74f13..35585317 100644 --- a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt @@ -6,26 +6,61 @@ import com.firsttimeinforever.intellij.pdf.viewer.structureView.PdfStructureView import com.firsttimeinforever.intellij.pdf.viewer.ui.editor.view.PdfEditorViewComponent import com.intellij.diff.util.FileEditorBase import com.intellij.ide.structureView.StructureViewBuilder +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.BulkFileListener import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import java.awt.Window +import java.awt.event.HierarchyListener +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent import javax.swing.JComponent +import javax.swing.SwingUtilities // TODO: Implement state persistence class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : FileEditorBase(), DumbAware { val viewComponent = PdfEditorViewComponent(project, virtualFile) private val messageBusConnection = project.messageBus.connect() private val fileChangedListener = FileChangedListener(PdfViewerSettings.instance.enableDocumentAutoReload) + private var lastKnownFileTimestamp = virtualFile.timeStamp + private var lastKnownFileLength = virtualFile.length + private val hierarchyListener = HierarchyListener { + attachWindowFocusListener() + } + private val windowFocusListener = object : WindowAdapter() { + override fun windowGainedFocus(e: WindowEvent) { + scheduleRefreshCheck() + } + } + private var attachedWindow: Window? = null init { Disposer.register(this, viewComponent) Disposer.register(this, messageBusConnection) + Disposer.register(this) { + detachWindowFocusListener() + viewComponent.removeHierarchyListener(hierarchyListener) + } + viewComponent.addHierarchyListener(hierarchyListener) + ApplicationManager.getApplication().invokeLater { + attachWindowFocusListener() + } messageBusConnection.subscribe(VirtualFileManager.VFS_CHANGES, fileChangedListener) + messageBusConnection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener { + override fun selectionChanged(event: FileEditorManagerEvent) { + if (event.newEditor === this@PdfFileEditor) { + scheduleRefreshCheck() + } + } + }) messageBusConnection.subscribe(PdfViewerSettings.TOPIC, PdfViewerSettingsListener { fileChangedListener.isEnabled = it.enableDocumentAutoReload }) @@ -39,16 +74,53 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi override fun getPreferredFocusedComponent(): JComponent = viewComponent.controlPanel + private fun scheduleRefreshCheck() { + ApplicationManager.getApplication().executeOnPooledThread { + refreshAndReloadIfChanged() + } + } + + private fun attachWindowFocusListener() { + val window = SwingUtilities.getWindowAncestor(viewComponent) ?: return + if (window === attachedWindow) { + return + } + detachWindowFocusListener() + attachedWindow = window + window.addWindowFocusListener(windowFocusListener) + } + + private fun detachWindowFocusListener() { + attachedWindow?.removeWindowFocusListener(windowFocusListener) + attachedWindow = null + } + + private fun refreshAndReloadIfChanged() { + val refreshedFile = LocalFileSystem.getInstance().refreshAndFindFileByPath(virtualFile.path) ?: return + if (refreshedFile.timeStamp == lastKnownFileTimestamp && refreshedFile.length == lastKnownFileLength) { + return + } + + val controller = viewComponent.controller ?: run { + logger.warn("Target file ${virtualFile.path} changed on disk, but the PDF controller is not ready yet.") + return + } + + lastKnownFileTimestamp = refreshedFile.timeStamp + lastKnownFileLength = refreshedFile.length + logger.debug("Target file ${virtualFile.path} changed on disk. Reloading current view.") + ApplicationManager.getApplication().executeOnPooledThread { + controller.reload(tryToPreserveState = true) + } + } + private inner class FileChangedListener(var isEnabled: Boolean = true) : BulkFileListener { override fun after(events: MutableList) { if (!isEnabled) { return } - if (viewComponent.controller == null) { - logger.warn("FileChangedListener was called for view with controller == null!") - } else if (events.any { it.file == virtualFile }) { - logger.debug("Target file ${virtualFile.path} changed. Reloading current view.") - viewComponent.controller.reload(tryToPreserveState = true) + if (events.any { it.file?.path == virtualFile.path || it.path == virtualFile.path }) { + scheduleRefreshCheck() } } } diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/view/PdfJcefPreviewController.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/view/PdfJcefPreviewController.kt index ad2718a6..327db96a 100644 --- a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/view/PdfJcefPreviewController.kt +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/view/PdfJcefPreviewController.kt @@ -29,6 +29,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.JBColor import com.intellij.ui.jcef.JCEFHtmlPanel @@ -159,9 +160,15 @@ class PdfJcefPreviewController(val project: Project, val virtualFile: VirtualFil val component get() = browser.component private fun doActualReload(tryToPreserveState: Boolean = false) { - viewLoaded = false try { - val base = PdfStaticServer.instance.getPreviewUrl(virtualFile, withReloadSalt = true) + val refreshedFile = LocalFileSystem.getInstance().refreshAndFindFileByPath(virtualFile.path) + if (refreshedFile == null) { + logger.warn("Could not refresh file before reload: ${virtualFile.path}") + return + } + + viewLoaded = false + val base = PdfStaticServer.instance.getPreviewUrl(refreshedFile, withReloadSalt = true) if (firstLoad) { viewState = viewState.copy(sidebarViewMode = PdfViewerSettings.instance.defaultSidebarViewMode) } From d5418ccad12d4a97731d6540b3799463039cadbb Mon Sep 17 00:00:00 2001 From: Thomas Schouten <15669080+PHPirates@users.noreply.github.com> Date: Thu, 28 May 2026 10:23:44 +0200 Subject: [PATCH 2/3] Claude: bypass all IntelliJ apis, now it seems to refresh --- .gitignore | 1 + gradle.properties | 2 +- plugin/build.gradle.kts | 3 + .../pdf/viewer/ui/editor/DiskFileWatcher.kt | 133 ++++++++++++++++++ .../pdf/viewer/ui/editor/PdfFileEditor.kt | 33 ++++- 5 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt diff --git a/.gitignore b/.gitignore index e92179e1..b5751ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .gradle .idea/* +.junie build /src/main/resources/sentry.properties /src/main/web-view/node_modules/ diff --git a/gradle.properties b/gradle.properties index 5031cb36..0dd06ef7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ pluginName = PDF Viewer group = com.firsttimeinforever.intellij.pdf.viewer -version = 0.18.1-alpha.1 +version = 0.18.2-alpha.1 # To run with AS 2021.3.1 Canary 5 #platformVersion = 213.6777.52 diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index c0d05963..95e0805e 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -54,6 +54,9 @@ dependencies { exclude("org.slf4j") exclude("com.fasterxml.jackson.core", "jackson-core") } + // Provided at runtime by the IntelliJ Platform; compileOnly per + // https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#coroutinesLibraries + compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") // kotlinx.serialization also should be present in the platform implementation(project(":mpi")) { exclude("org.jetbrains.kotlinx", "kotlinx-serialization-json") diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt new file mode 100644 index 00000000..8d2897e6 --- /dev/null +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt @@ -0,0 +1,133 @@ +package com.firsttimeinforever.intellij.pdf.viewer.ui.editor + +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.logger +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import java.io.IOException +import java.nio.file.ClosedWatchServiceException +import java.nio.file.Path +import java.nio.file.ProviderMismatchException +import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE +import java.nio.file.StandardWatchEventKinds.ENTRY_DELETE +import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY +import java.nio.file.WatchService +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException + +/** + * Watches the parent directory of [filePath] via a JDK NIO [WatchService] and invokes [onChange] + * shortly after the watched filename is created or modified on disk. A short debounce coalesces + * bursts of events that non-atomic writers produce while a file is still being written. + * + * On macOS the JDK falls back to a polling implementation (~10s), so the existing focus-based + * refresh in [PdfFileEditor] remains the faster path on that platform. + */ +class DiskFileWatcher(filePath: Path, private val onChange: () -> Unit) : Disposable { + private val fileName: Path? = filePath.fileName + private val parent: Path? = filePath.parent + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineName("pdf-viewer-disk-watcher-${filePath.fileName}") + ) + private val watchService: WatchService? + private val debounceLock = Any() + private var debounceJob: Job? = null + private val closed = AtomicBoolean(false) + + init { + watchService = openWatchService() + if (watchService != null) { + scope.launch { runWatchLoop(watchService) } + } + } + + private fun openWatchService(): WatchService? { + if (parent == null || fileName == null) { + logger.debug("Disk watcher unavailable: no parent directory for path") + return null + } + var service: WatchService? = null + return try { + service = parent.fileSystem.newWatchService() + parent.register(service, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE) + service + } catch (e: IOException) { + logger.debug("Disk watcher unavailable for $parent", e) + service?.runCatching { close() } + null + } catch (e: UnsupportedOperationException) { + logger.debug("Disk watcher unavailable for $parent", e) + service?.runCatching { close() } + null + } catch (e: ProviderMismatchException) { + logger.debug("Disk watcher unavailable for $parent", e) + service?.runCatching { close() } + null + } catch (e: SecurityException) { + logger.debug("Disk watcher unavailable for $parent", e) + service?.runCatching { close() } + null + } + } + + private suspend fun runWatchLoop(service: WatchService) { + try { + while (scope.isActive) { + val key = runInterruptible { service.take() } + try { + for (event in key.pollEvents()) { + val context = event.context() as? Path ?: continue + if (context.fileName != fileName) continue + val kind = event.kind() + if (kind == ENTRY_CREATE || kind == ENTRY_MODIFY) { + scheduleDebounced() + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.debug("Ignoring error while handling watch events", e) + } + if (!key.reset()) { + logger.debug("Watch key for $parent is no longer valid; stopping watcher") + return + } + } + } catch (_: ClosedWatchServiceException) { + // Normal shutdown via dispose(). + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.debug("Disk watcher loop crashed", e) + } + } + + private fun scheduleDebounced() { + synchronized(debounceLock) { + debounceJob?.cancel() + debounceJob = scope.launch { + delay(DEBOUNCE_MS) + onChange() + } + } + } + + override fun dispose() { + if (!closed.compareAndSet(false, true)) return + watchService?.runCatching { close() } + scope.cancel() + } + + companion object { + private const val DEBOUNCE_MS = 200L + private val logger = logger() + } +} diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt index 35585317..64689b59 100644 --- a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt @@ -22,6 +22,10 @@ import java.awt.Window import java.awt.event.HierarchyListener import java.awt.event.WindowAdapter import java.awt.event.WindowEvent +import java.io.IOException +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Paths import javax.swing.JComponent import javax.swing.SwingUtilities @@ -31,7 +35,6 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi private val messageBusConnection = project.messageBus.connect() private val fileChangedListener = FileChangedListener(PdfViewerSettings.instance.enableDocumentAutoReload) private var lastKnownFileTimestamp = virtualFile.timeStamp - private var lastKnownFileLength = virtualFile.length private val hierarchyListener = HierarchyListener { attachWindowFocusListener() } @@ -64,6 +67,12 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi messageBusConnection.subscribe(PdfViewerSettings.TOPIC, PdfViewerSettingsListener { fileChangedListener.isEnabled = it.enableDocumentAutoReload }) + try { + val watcher = DiskFileWatcher(Paths.get(virtualFile.path)) { scheduleRefreshCheck() } + Disposer.register(this, watcher) + } catch (e: InvalidPathException) { + logger.debug("Disk watcher unavailable for ${virtualFile.path}", e) + } } override fun getName(): String = NAME @@ -96,8 +105,21 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi } private fun refreshAndReloadIfChanged() { - val refreshedFile = LocalFileSystem.getInstance().refreshAndFindFileByPath(virtualFile.path) ?: return - if (refreshedFile.timeStamp == lastKnownFileTimestamp && refreshedFile.length == lastKnownFileLength) { + val filePath = try { + Paths.get(virtualFile.path) + } catch (e: InvalidPathException) { + logger.warn("Invalid file path for disk timestamp check: ${virtualFile.path}", e) + return + } + + val diskTimestamp = try { + Files.getLastModifiedTime(filePath).toMillis() + } catch (e: IOException) { + logger.warn("Failed to read last modified timestamp for ${virtualFile.path}", e) + return + } + + if (diskTimestamp == lastKnownFileTimestamp) { return } @@ -106,9 +128,8 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi return } - lastKnownFileTimestamp = refreshedFile.timeStamp - lastKnownFileLength = refreshedFile.length - logger.debug("Target file ${virtualFile.path} changed on disk. Reloading current view.") + lastKnownFileTimestamp = diskTimestamp + logger.warn("Target file ${virtualFile.path} changed on disk. Reloading current view.") ApplicationManager.getApplication().executeOnPooledThread { controller.reload(tryToPreserveState = true) } From 8bb8485121afe5ba97e8fafdd435bba407f84497 Mon Sep 17 00:00:00 2001 From: Thomas Schouten <15669080+PHPirates@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:10:48 +0200 Subject: [PATCH 3/3] Add setting to enable file watcher --- CHANGELOG.md | 3 ++ README.md | 2 ++ gradle.properties | 2 +- .../viewer/settings/PdfViewerConfigurable.kt | 2 ++ .../pdf/viewer/settings/PdfViewerSettings.kt | 1 + .../viewer/settings/PdfViewerSettingsForm.kt | 7 ++++ .../pdf/viewer/ui/editor/DiskFileWatcher.kt | 9 ++++-- .../pdf/viewer/ui/editor/PdfFileEditor.kt | 32 +++++++++++++++---- .../messages/PdfViewerBundle.properties | 1 + 9 files changed, 49 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00bb0620..7ca71d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Intellij PDF Viewer Plugin Changelog +## 0.18.2 +- Add a setting to enable a system file watcher, which automatically detects any external changes to the pdf + ## 0.18.1 - Hide the sidebar switcher automatically after changing sidebar mode diff --git a/README.md b/README.md index a5951145..658476c1 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - Horizontal/vertical pages scroll directions - Customizable toolbar - Customizable dark mode +- File watcher All the settings can be found in Settings | Other settings | PDF Viewer. @@ -44,6 +45,7 @@ All the settings can be found in Settings | Other settings | PDF Viewer. * Right-click on a pdf to open it in PDFium, the default Chrome PDF viewer. If you do not see a context menu, it may help to go to Help > Find Action, search for Registry, set `pdf.viewer.use.jcef.osr.view` to false, and restart. See [IJPL-59459](https://youtrack.jetbrains.com/issue/IJPL-59459/Context-menu-does-not-work-for-OSR-Cef-browser) for more details. * To view pdfs in a Code With Me session, you may need to install the PDF viewer plugin (0.17.0 or later) in the client as well. This feature is still expirimental, for more info see [CWM-1199](https://youtrack.jetbrains.com/issue/CWM-1199). * When you have the pdf open in a separate window and you want inverse search to open the LaTeX file in the main window when it is not open yet, go to Settings > Advanced settings and enable "Open declaration source called from a detached window in the main IDE window" +* You only need to enable the file watcher global setting if you are using some program to automatically update the pdf which is running in the terminal (or at least outside the IDE), since IntelliJ would not be triggered to look for file system changes. You do not need this setting when using TeXiFy. ## Use cases diff --git a/gradle.properties b/gradle.properties index a27237f6..53e0e9a1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ pluginName = PDF Viewer group = com.firsttimeinforever.intellij.pdf.viewer -version = 0.18.2-alpha.1 +version = 0.18.2 # To run with AS 2021.3.1 Canary 5 #platformVersion = 213.6777.52 diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerConfigurable.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerConfigurable.kt index a11ea7b5..8ca2d927 100644 --- a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerConfigurable.kt +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerConfigurable.kt @@ -11,6 +11,7 @@ class PdfViewerConfigurable : Configurable { override fun isModified(): Boolean { return settingsForm?.run { settings.enableDocumentAutoReload != enableDocumentAutoReload.get() || + settings.enableFileWatcher != enableFileWatcher.get() || settings.defaultSidebarViewMode != defaultSidebarViewMode.get() || settings.scrollPixelsPerStep != scrollPixelsPerStep.get() || settings.invertColorsWithTheme != invertDocumentColorsWithTheme.get() || @@ -29,6 +30,7 @@ class PdfViewerConfigurable : Configurable { val wasModified = isModified settings.run { enableDocumentAutoReload = settingsForm?.enableDocumentAutoReload?.get() ?: enableDocumentAutoReload + enableFileWatcher = settingsForm?.enableFileWatcher?.get() ?: enableFileWatcher defaultSidebarViewMode = settingsForm?.defaultSidebarViewMode?.get() ?: defaultSidebarViewMode scrollPixelsPerStep = settingsForm?.scrollPixelsPerStep?.get() ?: scrollPixelsPerStep invertColorsWithTheme = settingsForm?.invertDocumentColorsWithTheme?.get() ?: invertColorsWithTheme diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerSettings.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerSettings.kt index ebb9c3f3..b313fb16 100644 --- a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerSettings.kt +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerSettings.kt @@ -18,6 +18,7 @@ class PdfViewerSettings : PersistentStateComponent { var customForegroundColor: Int = defaultForegroundColor.rgb var customIconColor: Int = defaultIconColor.rgb var enableDocumentAutoReload = true + var enableFileWatcher = false var documentColorsInvertIntensity: Int = defaultDocumentColorsInvertIntensity var invertDocumentColors = false var invertColorsWithTheme = false diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerSettingsForm.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerSettingsForm.kt index ee6a01ef..4bec544f 100644 --- a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerSettingsForm.kt +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/settings/PdfViewerSettingsForm.kt @@ -20,6 +20,7 @@ class PdfViewerSettingsForm : JPanel() { private val properties = PropertyGraph() val enableDocumentAutoReload = properties.property(settings.enableDocumentAutoReload) + val enableFileWatcher = properties.property(settings.enableFileWatcher) val defaultSidebarViewMode = properties.property(settings.defaultSidebarViewMode) val scrollPixelsPerStep = properties.property(settings.scrollPixelsPerStep) @@ -29,6 +30,11 @@ class PdfViewerSettingsForm : JPanel() { checkBox(PdfViewerBundle.message("pdf.viewer.settings.reload.document")) .bindSelected(enableDocumentAutoReload) } + row { + //noinspection UnresolvedPropertyKey + checkBox(PdfViewerBundle.message("pdf.viewer.settings.enable.file.watcher")) + .bindSelected(enableFileWatcher) + } row(PdfViewerBundle.message("pdf.viewer.settings.sidebar.viewer.default")) { val renderer = SimpleListCellRenderer.create { label, value, _ -> label.text = when (value) { @@ -141,6 +147,7 @@ class PdfViewerSettingsForm : JPanel() { fun reset() { enableDocumentAutoReload.set(settings.enableDocumentAutoReload) + enableFileWatcher.set(settings.enableFileWatcher) defaultSidebarViewMode.set(settings.defaultSidebarViewMode) invertDocumentColorsWithTheme.set(settings.invertColorsWithTheme) invertDocumentColors.set(settings.invertDocumentColors) diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt index 8d2897e6..abbdea3a 100644 --- a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt @@ -22,14 +22,19 @@ import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY import java.nio.file.WatchService import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.milliseconds /** + * Intended for when the user uses an external program which updates the pdf, see e.g. #150. + * Since the VFS will not refresh without trigger, we cannot use the IntelliJ apis to implement a watcher. + * Therefore, we work around them manually. + * * Watches the parent directory of [filePath] via a JDK NIO [WatchService] and invokes [onChange] * shortly after the watched filename is created or modified on disk. A short debounce coalesces * bursts of events that non-atomic writers produce while a file is still being written. * * On macOS the JDK falls back to a polling implementation (~10s), so the existing focus-based - * refresh in [PdfFileEditor] remains the faster path on that platform. + * refresh in [PdfFileEditor] may be preferred by users (but requires switching focus away from the IDe.). */ class DiskFileWatcher(filePath: Path, private val onChange: () -> Unit) : Disposable { private val fileName: Path? = filePath.fileName @@ -114,7 +119,7 @@ class DiskFileWatcher(filePath: Path, private val onChange: () -> Unit) : Dispos synchronized(debounceLock) { debounceJob?.cancel() debounceJob = scope.launch { - delay(DEBOUNCE_MS) + delay(DEBOUNCE_MS.milliseconds) onChange() } } diff --git a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt index 64689b59..9633009f 100644 --- a/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/PdfFileEditor.kt @@ -13,7 +13,6 @@ import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer -import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.BulkFileListener @@ -43,6 +42,8 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi scheduleRefreshCheck() } } + private val fileWatcherLock = Any() + private var diskFileWatcher: DiskFileWatcher? = null private var attachedWindow: Window? = null init { @@ -51,6 +52,7 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi Disposer.register(this) { detachWindowFocusListener() viewComponent.removeHierarchyListener(hierarchyListener) + setFileWatcherEnabled(false) } viewComponent.addHierarchyListener(hierarchyListener) ApplicationManager.getApplication().invokeLater { @@ -66,13 +68,9 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi }) messageBusConnection.subscribe(PdfViewerSettings.TOPIC, PdfViewerSettingsListener { fileChangedListener.isEnabled = it.enableDocumentAutoReload + setFileWatcherEnabled(it.enableFileWatcher) }) - try { - val watcher = DiskFileWatcher(Paths.get(virtualFile.path)) { scheduleRefreshCheck() } - Disposer.register(this, watcher) - } catch (e: InvalidPathException) { - logger.debug("Disk watcher unavailable for ${virtualFile.path}", e) - } + setFileWatcherEnabled(PdfViewerSettings.instance.enableFileWatcher) } override fun getName(): String = NAME @@ -89,6 +87,26 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi } } + private fun setFileWatcherEnabled(isEnabled: Boolean) { + synchronized(fileWatcherLock) { + if (!isEnabled) { + diskFileWatcher?.dispose() + diskFileWatcher = null + return + } + if (diskFileWatcher != null) { + return + } + val filePath = try { + Paths.get(virtualFile.path) + } catch (e: InvalidPathException) { + logger.debug("Disk watcher unavailable for ${virtualFile.path}", e) + return + } + diskFileWatcher = DiskFileWatcher(filePath) { scheduleRefreshCheck() } + } + } + private fun attachWindowFocusListener() { val window = SwingUtilities.getWindowAncestor(viewComponent) ?: return if (window === attachedWindow) { diff --git a/plugin/src/main/resources/messages/PdfViewerBundle.properties b/plugin/src/main/resources/messages/PdfViewerBundle.properties index 767843de..06d01698 100644 --- a/plugin/src/main/resources/messages/PdfViewerBundle.properties +++ b/plugin/src/main/resources/messages/PdfViewerBundle.properties @@ -17,6 +17,7 @@ pdf.viewer.document.info.linearized=Linearized pdf.viewer.settings.display.name=PDF Viewer pdf.viewer.settings.group.general=General pdf.viewer.settings.reload.document=Reload document on change +pdf.viewer.settings.enable.file.watcher=Use disk file watcher to detect external changes pdf.viewer.settings.sidebar.viewer.default=Default sidebar view mode pdf.viewer.settings.scroll.pixels.per.step=Scroll pixels per step