Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run IDE for UI Tests" type="GradleRunConfiguration" factoryName="Gradle">
<configuration default="false" name="Run Plugin (IDEA)" type="GradleRunConfiguration" factoryName="Gradle">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
Expand All @@ -11,15 +11,14 @@
</option>
<option name="taskNames">
<list>
<option value="runIdeForUiTests" />
<option value="runIdeIdea" />
</list>
</option>
<option name="vmOptions" />
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
</component>
35 changes: 21 additions & 14 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import org.jetbrains.changelog.Changelog
import org.jetbrains.changelog.markdownToHTML
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
import org.gradle.api.tasks.testing.TestDescriptor
import org.gradle.api.tasks.testing.TestResult
Expand All @@ -18,6 +19,19 @@ plugins {
group = providers.gradleProperty("pluginGroup").get()
version = providers.gradleProperty("pluginVersion").get()

// -Pidea switches from Gateway (GW) to IntelliJ IDEA (IU)
val isIdea = providers.gradleProperty("idea").isPresent
val resolvedPlatformType = if (isIdea) {
"IU"
} else {
providers.gradleProperty("platformType").get()
}
val resolvedBundledPlugins = if (isIdea) {
"com.jetbrains.gateway"
} else {
providers.gradleProperty("platformBundledPlugins").get()
}

// Set the JVM language level used to build the project.
kotlin {
jvmToolchain(21)
Expand Down Expand Up @@ -56,10 +70,10 @@ dependencies {

// IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html
intellijPlatform {
create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion"))
create(resolvedPlatformType, providers.gradleProperty("platformVersion"))

// Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins.
bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') })
bundledPlugins(resolvedBundledPlugins.split(',').filter { it.isNotBlank() })

// Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file for plugin from JetBrains Marketplace.
plugins(providers.gradleProperty("platformPlugins").map { it.split(',') })
Expand Down Expand Up @@ -227,20 +241,13 @@ tasks {

intellijPlatformTesting {
runIde {
register("runIdeForUiTests") {
task {
jvmArgumentProviders += CommandLineArgumentProvider {
listOf(
"-Drobot-server.port=8082",
"-Dide.mac.message.dialogs.as.sheets=false",
"-Djb.privacy.policy.text=<!--999.999-->",
"-Djb.consents.confirmation.enabled=false",
)
}
}
// Visible next to runIde under "intellij platform" in the Gradle tool window
register("runIdeIdea") {
type = IntelliJPlatformType.IntellijIdeaUltimate
version = providers.gradleProperty("platformVersion")

plugins {
robotServerPlugin()
bundledPlugin("com.jetbrains.gateway")
Comment on lines +244 to +250

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve or migrate the UI-test Gradle task.

.github/workflows/run-ui-tests.yml still invokes runIdeForUiTests on Linux, Windows, and macOS. Removing that task causes every UI-test job to fail before the IDE starts. Update the workflow atomically or retain a compatibility task.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@build.gradle.kts` around lines 244 - 250, Preserve the runIdeForUiTests task
compatibility while adding runIdeIdea, or update every invocation in
.github/workflows/run-ui-tests.yml atomically to use the replacement task.
Ensure Linux, Windows, and macOS UI-test jobs can still launch the IDE
successfully.

}
}
}
Expand Down
2 changes: 0 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ pluginVersion = 0.0.18

# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 251
#pluginUntilBuild = 252.*

# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension
platformType = GW
Expand All @@ -20,7 +19,6 @@ platformVersion = 2025.1.1
platformPlugins =
# Example: platformBundledPlugins = com.intellij.java
platformBundledPlugins =

# Gradle Releases -> https://github.com/gradle/gradle/releases
gradleVersion = 8.10.2

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ package com.redhat.devtools.gateway.kubeconfig
import kotlinx.coroutines.*
import java.nio.file.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.io.path.exists
import kotlin.io.path.isRegularFile

Expand All @@ -23,80 +25,144 @@ class FileWatcher(
private val watchService: WatchService = FileSystems.getDefault().newWatchService()
) {
private var onFileChanged: ((Path) -> Unit)? = null
private val registeredKeys = ConcurrentHashMap<WatchKey, Path>()
private val registeredDirectories = ConcurrentHashMap<WatchKey, Path>()
private val monitoredFiles = ConcurrentHashMap.newKeySet<Path>()
private val notifyLock = ReentrantLock()
private var watchJob: Job? = null

fun start() {
this.watchJob = scope.launch(dispatcher) {
try {
while (isActive) {
val key = watchService.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
if (key == null) {
@Suppress("ConvertLongToDuration")
delay(100)
continue
}
val dir = registeredKeys[key] ?: continue
pollEvents(key, dir)
key.reset()
pollDirectoryEvents()
}
}

private suspend fun CoroutineScope.pollDirectoryEvents() {
try {
while (isActive) {
val key = watchService.poll(100, TimeUnit.MILLISECONDS)
if (key == null) {
@Suppress("ConvertLongToDuration")
delay(100)
continue
}
} catch (e: ClosedWatchServiceException) {
// Watch service was closed, exit gracefully
val dir = registeredDirectories[key] ?: continue
handleDirectoryEvent(key, dir)
key.reset()
}
} catch (_: ClosedWatchServiceException) {
// Watch service was closed, exit gracefully
}
}

fun stop() {
watchJob?.cancel()
watchJob = null
registeredDirectories.keys.forEach { it.cancel() }
registeredDirectories.clear()
monitoredFiles.clear()
try {
watchService.close()
} catch (e: Exception) {
} catch (_: Exception) {
// Ignore exceptions when closing
}
}

/**
* Registers [path] for watching and invokes the [onFileChanged] callback if this is
* a new file that was not previously monitored. For non-existent files, the callback
* is invoked so listeners can track them for a later CREATE event. If [path] is
* already in the monitored set, no callback is issued — this avoids redundant refreshes.
*
* @param path the file to monitor; may be non-existent (e.g. a kubeconfig file expected
* to appear later). If [path.parent] is null, the path will not be registered.
* @return this instance for chaining.
*/
fun addFile(path: Path): FileWatcher {
if (!path.exists()
|| !path.isRegularFile()) {
return this
}
val parentDir = path.parent
if (parentDir != null
&& !monitoredFiles.contains(path)) {
val watchKey = parentDir.register(watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE
)
registeredKeys[watchKey] = parentDir
registerDirectory(parentDir)
monitoredFiles.add(path)
onFileChanged?.invoke(path)
invokeOnFileChanged(path)
}
return this
}
Comment on lines 80 to 89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unhandled IOException when a monitored file's parent directory doesn't exist yet.

addFile() was changed to support tracking non-existent files (per the docstring), but registerDirectory() still calls directory.register(...) unconditionally. If the parent directory itself doesn't exist (a plausible case for a custom KUBECONFIG entry or a not-yet-mounted config dir — exactly the scenario this feature targets), Path.register() throws IOException/NoSuchFileException, which propagates uncaught through addFile()KubeConfigMonitor.startWatchingNew()/updateMonitoredPaths()/start(), which per the linked snippet in DevSpacesServerStepView has no surrounding try/catch.

🛡️ Proposed fix
     private fun registerDirectory(directory: Path) {
         if (registeredDirectories.values.any { it == directory }) {
             return
         }
-        val watchKey = directory.register(
-            watchService,
-            StandardWatchEventKinds.ENTRY_CREATE,
-            StandardWatchEventKinds.ENTRY_MODIFY,
-            StandardWatchEventKinds.ENTRY_DELETE
-        )
-        registeredDirectories[watchKey] = directory
+        try {
+            val watchKey = directory.register(
+                watchService,
+                StandardWatchEventKinds.ENTRY_CREATE,
+                StandardWatchEventKinds.ENTRY_MODIFY,
+                StandardWatchEventKinds.ENTRY_DELETE
+            )
+            registeredDirectories[watchKey] = directory
+        } catch (e: IOException) {
+            // Parent directory doesn't exist yet; the file stays in monitoredFiles
+            // but won't be watched until a later addFile()/updateMonitoredPaths() retries.
+        }
     }

Also applies to: 116-127

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt` around
lines 80 - 89, Update addFile and the registerDirectory flow in FileWatcher so a
missing parent directory does not let IOException or NoSuchFileException escape
when tracking a not-yet-existing file. Handle the failed directory registration
consistently with the watcher’s existing lifecycle, while still retaining the
file for later monitoring once its parent becomes available; preserve normal
registration behavior for existing directories.


fun removeFile(path: Path): FileWatcher {
monitoredFiles.remove(path)
if (!monitoredFiles.remove(path)) {
return this
}
val parentDir = path.parent ?: return this
if (monitoredFiles.none { it.parent == parentDir }) {
unregisterDirectory(parentDir)
}
return this
}

fun getMonitoredFiles(): Set<Path> = monitoredFiles

/** Parent directories that currently have an active [WatchKey]. For tests. */
internal fun getWatchedDirectories(): Set<Path> = registeredDirectories.values.toSet()

fun onFileChanged(action: ((Path) -> Unit)?) {
this.onFileChanged = action
notifyLock.lock()
try {
this.onFileChanged = action
} finally {
notifyLock.unlock()
}
}

private fun pollEvents(key: WatchKey, dir: Path) {
private fun registerDirectory(directory: Path) {
if (registeredDirectories.values.any { it == directory }) {
return
}
val watchKey = directory.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE
)
registeredDirectories[watchKey] = directory
}

private fun unregisterDirectory(directory: Path) {
val keys = registeredDirectories.filterValues { it == directory }.keys
keys.forEach { key ->
registeredDirectories.remove(key)
key.cancel()
}
}

private fun handleDirectoryEvent(key: WatchKey, dir: Path) {
for (event in key.pollEvents()) {
val relativePath = event.context() as? Path ?: continue
val changedFile = dir.resolve(relativePath)
val kind = event.kind()

if (monitoredFiles.contains(changedFile)
&& event.kind() != StandardWatchEventKinds.OVERFLOW
if (!monitoredFiles.contains(changedFile)
|| kind == StandardWatchEventKinds.OVERFLOW
) {
onFileChanged?.invoke(changedFile)
continue
}

// DELETE: file is already gone — still notify so callers can refresh.
// CREATE/MODIFY: only notify for an existing regular file.
val shouldNotify = kind == StandardWatchEventKinds.ENTRY_DELETE
|| (changedFile.exists() && changedFile.isRegularFile())

if (shouldNotify) {
invokeOnFileChanged(changedFile)
}
}
}

private fun invokeOnFileChanged(path: Path) {
notifyLock.lock()
try {
onFileChanged?.invoke(path)
} finally {
notifyLock.unlock()
}
}
}
Loading