Skip to content

Commit 4cfd3a0

Browse files
committed
feat: add output directory watcher for instant VFS refresh
Add automatic VFS refresh when trc -w compiles files, solving the issue where JetBrains IDEs cache compiled .rb files and don't detect external changes immediately. - Add TrbConfig to parse trbconfig.yml for output directory path - Add OutputDirectoryWatcher using Java WatchService - Add TRubyProjectStartupActivity to start watcher on project open - Use VfsUtil.markDirtyAndRefresh for immediate IDE refresh
1 parent aa5886f commit 4cfd3a0

4 files changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package io.truby.intellij.config
2+
3+
import com.intellij.openapi.project.Project
4+
import java.io.File
5+
6+
/**
7+
* Parser for trbconfig.yml configuration file.
8+
* Reads the output directory path for VFS refresh functionality.
9+
*
10+
* Uses simple regex-based parsing to avoid SnakeYAML dependency conflicts
11+
* with IntelliJ Platform's bundled version.
12+
*/
13+
class TrbConfig private constructor(
14+
val rubyDir: String,
15+
val rbsDir: String?
16+
) {
17+
companion object {
18+
private const val CONFIG_FILE_NAME = "trbconfig.yml"
19+
private const val DEFAULT_RUBY_DIR = "build"
20+
21+
// Regex to match "ruby_dir: value" (handles quoted and unquoted values)
22+
private val RUBY_DIR_PATTERN = Regex("""^\s*ruby_dir:\s*["']?([^"'\s#]+)["']?""", RegexOption.MULTILINE)
23+
private val RBS_DIR_PATTERN = Regex("""^\s*rbs_dir:\s*["']?([^"'\s#]+)["']?""", RegexOption.MULTILINE)
24+
25+
/**
26+
* Load configuration from project root.
27+
* Returns null if config file doesn't exist.
28+
*/
29+
fun load(project: Project): TrbConfig? {
30+
val basePath = project.basePath ?: return null
31+
val configFile = File(basePath, CONFIG_FILE_NAME)
32+
33+
if (!configFile.exists()) {
34+
return null
35+
}
36+
37+
return try {
38+
parse(configFile)
39+
} catch (e: Exception) {
40+
// If parsing fails, return default config
41+
TrbConfig(DEFAULT_RUBY_DIR, null)
42+
}
43+
}
44+
45+
/**
46+
* Get output directory path, using default if config doesn't exist.
47+
*/
48+
fun getOutputDir(project: Project): String {
49+
return load(project)?.rubyDir ?: DEFAULT_RUBY_DIR
50+
}
51+
52+
private fun parse(configFile: File): TrbConfig {
53+
val content = configFile.readText()
54+
55+
val rubyDir = RUBY_DIR_PATTERN.find(content)?.groupValues?.get(1) ?: DEFAULT_RUBY_DIR
56+
val rbsDir = RBS_DIR_PATTERN.find(content)?.groupValues?.get(1)
57+
58+
return TrbConfig(rubyDir, rbsDir)
59+
}
60+
}
61+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package io.truby.intellij.watcher
2+
3+
import com.intellij.openapi.Disposable
4+
import com.intellij.openapi.application.ApplicationManager
5+
import com.intellij.openapi.components.Service
6+
import com.intellij.openapi.diagnostic.Logger
7+
import com.intellij.openapi.project.Project
8+
import com.intellij.openapi.vfs.LocalFileSystem
9+
import com.intellij.openapi.vfs.VfsUtil
10+
import io.truby.intellij.config.TrbConfig
11+
import java.io.File
12+
import java.nio.file.*
13+
import java.util.concurrent.atomic.AtomicBoolean
14+
import kotlin.concurrent.thread
15+
16+
/**
17+
* Watches the T-Ruby output directory for changes and triggers VFS refresh.
18+
* This ensures that files compiled by `trc -w` are immediately visible in the IDE.
19+
*/
20+
@Service(Service.Level.PROJECT)
21+
class OutputDirectoryWatcher(private val project: Project) : Disposable {
22+
private val logger = Logger.getInstance(OutputDirectoryWatcher::class.java)
23+
private var watchThread: Thread? = null
24+
private var watchService: WatchService? = null
25+
private val isRunning = AtomicBoolean(false)
26+
private var currentWatchDir: String? = null
27+
28+
/**
29+
* Start watching the output directory.
30+
* Called automatically when project opens.
31+
*/
32+
fun start() {
33+
if (isRunning.get()) {
34+
logger.info("OutputDirectoryWatcher already running")
35+
return
36+
}
37+
38+
val outputDir = TrbConfig.getOutputDir(project)
39+
val basePath = project.basePath ?: return
40+
41+
val watchPath = File(basePath, outputDir).toPath()
42+
43+
// Create directory if it doesn't exist
44+
if (!Files.exists(watchPath)) {
45+
try {
46+
Files.createDirectories(watchPath)
47+
} catch (e: Exception) {
48+
logger.warn("Failed to create output directory: $watchPath", e)
49+
return
50+
}
51+
}
52+
53+
currentWatchDir = watchPath.toString()
54+
startWatching(watchPath)
55+
}
56+
57+
/**
58+
* Stop watching and clean up resources.
59+
*/
60+
fun stop() {
61+
isRunning.set(false)
62+
watchService?.close()
63+
watchThread?.interrupt()
64+
watchThread = null
65+
watchService = null
66+
currentWatchDir = null
67+
logger.info("OutputDirectoryWatcher stopped")
68+
}
69+
70+
/**
71+
* Restart watching (e.g., when config changes).
72+
*/
73+
fun restart() {
74+
stop()
75+
start()
76+
}
77+
78+
private fun startWatching(watchPath: Path) {
79+
try {
80+
watchService = FileSystems.getDefault().newWatchService()
81+
registerRecursively(watchPath)
82+
83+
isRunning.set(true)
84+
logger.info("Started watching: $watchPath")
85+
86+
watchThread = thread(name = "T-Ruby-OutputWatcher", isDaemon = true) {
87+
watchLoop()
88+
}
89+
} catch (e: Exception) {
90+
logger.error("Failed to start watching: $watchPath", e)
91+
}
92+
}
93+
94+
private fun registerRecursively(path: Path) {
95+
if (!Files.isDirectory(path)) return
96+
97+
path.register(
98+
watchService,
99+
StandardWatchEventKinds.ENTRY_CREATE,
100+
StandardWatchEventKinds.ENTRY_MODIFY,
101+
StandardWatchEventKinds.ENTRY_DELETE
102+
)
103+
104+
// Register subdirectories
105+
Files.list(path).use { stream ->
106+
stream.filter { Files.isDirectory(it) }
107+
.forEach { registerRecursively(it) }
108+
}
109+
}
110+
111+
private fun watchLoop() {
112+
while (isRunning.get()) {
113+
try {
114+
val key = watchService?.poll(500, java.util.concurrent.TimeUnit.MILLISECONDS)
115+
?: continue
116+
117+
val events = key.pollEvents()
118+
if (events.isNotEmpty()) {
119+
// Debounce: collect all events before refresh
120+
Thread.sleep(100)
121+
122+
// Process events
123+
for (event in events) {
124+
val kind = event.kind()
125+
val context = event.context() as? Path ?: continue
126+
127+
logger.debug("File event: $kind - $context")
128+
129+
// Register new directories for recursive watching
130+
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
131+
val watchable = key.watchable() as? Path
132+
val fullPath = watchable?.resolve(context)
133+
if (fullPath != null && Files.isDirectory(fullPath)) {
134+
registerRecursively(fullPath)
135+
}
136+
}
137+
}
138+
139+
// Trigger VFS refresh
140+
refreshVfs()
141+
}
142+
143+
if (!key.reset()) {
144+
logger.warn("Watch key is no longer valid")
145+
break
146+
}
147+
} catch (e: InterruptedException) {
148+
logger.info("Watch thread interrupted")
149+
break
150+
} catch (e: ClosedWatchServiceException) {
151+
logger.info("Watch service closed")
152+
break
153+
} catch (e: Exception) {
154+
logger.warn("Error in watch loop", e)
155+
}
156+
}
157+
}
158+
159+
private fun refreshVfs() {
160+
val watchDir = currentWatchDir ?: return
161+
162+
ApplicationManager.getApplication().invokeLater {
163+
if (project.isDisposed) return@invokeLater
164+
165+
val virtualFile = LocalFileSystem.getInstance().findFileByPath(watchDir)
166+
if (virtualFile != null) {
167+
VfsUtil.markDirtyAndRefresh(
168+
true, // async
169+
true, // recursive
170+
true, // reload children
171+
virtualFile
172+
)
173+
logger.debug("VFS refreshed: $watchDir")
174+
}
175+
}
176+
}
177+
178+
override fun dispose() {
179+
stop()
180+
}
181+
182+
companion object {
183+
fun getInstance(project: Project): OutputDirectoryWatcher {
184+
return project.getService(OutputDirectoryWatcher::class.java)
185+
}
186+
}
187+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.truby.intellij.watcher
2+
3+
import com.intellij.openapi.diagnostic.Logger
4+
import com.intellij.openapi.project.Project
5+
import com.intellij.openapi.startup.ProjectActivity
6+
import io.truby.intellij.config.TrbConfig
7+
8+
/**
9+
* Starts the OutputDirectoryWatcher when a T-Ruby project is opened.
10+
*/
11+
class TRubyProjectStartupActivity : ProjectActivity {
12+
private val logger = Logger.getInstance(TRubyProjectStartupActivity::class.java)
13+
14+
override suspend fun execute(project: Project) {
15+
// Only start watcher if this is a T-Ruby project (has trbconfig.yml)
16+
val config = TrbConfig.load(project)
17+
if (config == null) {
18+
logger.debug("No trbconfig.yml found, skipping OutputDirectoryWatcher")
19+
return
20+
}
21+
22+
logger.info("T-Ruby project detected, starting OutputDirectoryWatcher")
23+
OutputDirectoryWatcher.getInstance(project).start()
24+
}
25+
}

src/main/resources/META-INF/plugin.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@
6969
<applicationService
7070
serviceImplementation="io.truby.intellij.settings.TRubySettings"/>
7171

72+
<!-- Output Directory Watcher Service (auto-refresh VFS on trc -w changes) -->
73+
<projectService
74+
serviceImplementation="io.truby.intellij.watcher.OutputDirectoryWatcher"/>
75+
76+
<!-- Start watcher when T-Ruby project opens -->
77+
<postStartupActivity
78+
implementation="io.truby.intellij.watcher.TRubyProjectStartupActivity"/>
79+
7280
<!-- Additional Text Attributes for T-Ruby syntax highlighting -->
7381
<additionalTextAttributes scheme="Darcula" file="/colorSchemes/TRubyDarcula.xml"/>
7482
<additionalTextAttributes scheme="Dark" file="/colorSchemes/TRubyDarcula.xml"/>

0 commit comments

Comments
 (0)