diff --git a/.gitignore b/.gitignore index 250ab9bc..76f3e2c1 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/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 9c655f34..53e0e9a1 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.4 +version = 0.18.2 # 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 841d6f32..a1f7e0cb 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -55,6 +55,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/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 new file mode 100644 index 00000000..abbdea3a --- /dev/null +++ b/plugin/src/main/kotlin/com/firsttimeinforever/intellij/pdf/viewer/ui/editor/DiskFileWatcher.kt @@ -0,0 +1,138 @@ +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 +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] 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 + 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.milliseconds) + 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 89c74f13..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 @@ -6,7 +6,10 @@ 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 @@ -14,21 +17,60 @@ 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 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 // 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 val hierarchyListener = HierarchyListener { + attachWindowFocusListener() + } + private val windowFocusListener = object : WindowAdapter() { + override fun windowGainedFocus(e: WindowEvent) { + scheduleRefreshCheck() + } + } + private val fileWatcherLock = Any() + private var diskFileWatcher: DiskFileWatcher? = null + private var attachedWindow: Window? = null init { Disposer.register(this, viewComponent) Disposer.register(this, messageBusConnection) + Disposer.register(this) { + detachWindowFocusListener() + viewComponent.removeHierarchyListener(hierarchyListener) + setFileWatcherEnabled(false) + } + 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 + setFileWatcherEnabled(it.enableFileWatcher) }) + setFileWatcherEnabled(PdfViewerSettings.instance.enableFileWatcher) } override fun getName(): String = NAME @@ -39,16 +81,85 @@ class PdfFileEditor(project: Project, private val virtualFile: VirtualFile) : Fi override fun getPreferredFocusedComponent(): JComponent = viewComponent.controlPanel + private fun scheduleRefreshCheck() { + ApplicationManager.getApplication().executeOnPooledThread { + refreshAndReloadIfChanged() + } + } + + 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) { + return + } + detachWindowFocusListener() + attachedWindow = window + window.addWindowFocusListener(windowFocusListener) + } + + private fun detachWindowFocusListener() { + attachedWindow?.removeWindowFocusListener(windowFocusListener) + attachedWindow = null + } + + private fun refreshAndReloadIfChanged() { + 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 + } + + val controller = viewComponent.controller ?: run { + logger.warn("Target file ${virtualFile.path} changed on disk, but the PDF controller is not ready yet.") + return + } + + lastKnownFileTimestamp = diskTimestamp + logger.warn("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) } 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