Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.gradle
.idea/*
.junie
build
/src/main/resources/sentry.properties
/src/main/web-view/node_modules/
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() ||
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class PdfViewerSettings : PersistentStateComponent<PdfViewerSettings> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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<SidebarViewMode> { label, value, _ ->
label.text = when (value) {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DiskFileWatcher>()
}
}
Loading
Loading