feat: configurable screenshot detection

This commit is contained in:
Carl-Robert Linnupuu 2025-09-22 22:11:03 +01:00
parent 16117036cc
commit f83ce88984
9 changed files with 390 additions and 30 deletions

View file

@ -29,6 +29,7 @@ public class ConfigurationComponent {
private final JBTextField temperatureField;
private final CodeCompletionConfigurationForm codeCompletionForm;
private final ChatCompletionConfigurationForm chatCompletionForm;
private final ScreenshotConfigurationForm screenshotForm;
public ConfigurationComponent(
Disposable parentDisposable,
@ -74,6 +75,8 @@ public class ConfigurationComponent {
codeCompletionForm = new CodeCompletionConfigurationForm();
chatCompletionForm = new ChatCompletionConfigurationForm();
screenshotForm = new ScreenshotConfigurationForm();
screenshotForm.loadState(configuration.getScreenshotWatchPaths());
mainPanel = FormBuilder.createFormBuilder()
.addComponent(checkForPluginUpdatesCheckBox)
@ -84,6 +87,9 @@ public class ConfigurationComponent {
.addComponent(new TitledSeparator(
CodeGPTBundle.get("configurationConfigurable.section.assistant.title")))
.addComponent(createAssistantConfigurationForm())
.addComponent(new TitledSeparator(
CodeGPTBundle.get("configurationConfigurable.section.screenshots.title")))
.addComponent(screenshotForm.createPanel())
.addComponent(new TitledSeparator(
CodeGPTBundle.get("configurationConfigurable.section.codeCompletion.title")))
.addComponent(codeCompletionForm.createPanel())
@ -108,6 +114,11 @@ public class ConfigurationComponent {
state.setAutoFormattingEnabled(autoFormattingCheckBox.isSelected());
state.setCodeCompletionSettings(codeCompletionForm.getFormState());
state.setChatCompletionSettings(chatCompletionForm.getFormState());
var screenshotPaths = screenshotForm.getState();
state.getScreenshotWatchPaths().clear();
state.getScreenshotWatchPaths().addAll(screenshotPaths);
return state;
}
@ -121,6 +132,7 @@ public class ConfigurationComponent {
autoFormattingCheckBox.setSelected(configuration.getAutoFormattingEnabled());
codeCompletionForm.resetForm(configuration.getCodeCompletionSettings());
chatCompletionForm.resetForm(configuration.getChatCompletionSettings());
screenshotForm.loadState(configuration.getScreenshotWatchPaths());
}
// Formatted keys are not referenced in the messages bundle file

View file

@ -8,33 +8,49 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import ee.carlrobert.codegpt.actions.editor.EditorActionsUtil
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
import ee.carlrobert.codegpt.settings.configuration.ScreenshotPathDetector
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTService
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier
import ee.carlrobert.codegpt.ui.OverlayUtil
import com.intellij.openapi.diagnostic.thisLogger
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.absolutePathString
class CodeGPTProjectActivity : ProjectActivity {
private val watchExtensions = setOf("jpg", "jpeg", "png")
private val logger = thisLogger()
override suspend fun execute(project: Project) {
EditorActionsUtil.refreshActions()
project.service<CodeGPTService>().syncUserDetailsAsync()
if (!ApplicationManager.getApplication().isUnitTestMode
&& service<ConfigurationSettings>().state.checkForNewScreenshots
) {
val desktopPath = Paths.get(System.getProperty("user.home"), "Desktop")
project.service<FileWatcher>().watch(desktopPath) {
if (watchExtensions.contains(getFileExtension(it))) {
showImageAttachmentNotification(
project,
desktopPath.resolve(it).absolutePathString()
)
val configurationState = service<ConfigurationSettings>().state
val watchPaths = configurationState.screenshotWatchPaths.ifEmpty {
ScreenshotPathDetector.getDefaultPaths()
}
val watchExtensions = ScreenshotPathDetector.getDefaultFileExtensions().toSet()
logger.debug("Screenshot watch configuration - paths: $watchPaths, extensions: $watchExtensions")
val validPaths = watchPaths.filter { ScreenshotPathDetector.isValidWatchPath(it) }
logger.debug("Valid watch paths after filtering: $validPaths")
if (validPaths.isNotEmpty()) {
logger.info("Starting screenshot file watching for ${validPaths.size} paths")
project.service<FileWatcher>().watchMultiplePaths(validPaths) { fileName, watchPath ->
val fileExtension = getFileExtension(fileName)
logger.trace("File detected: fileName=$fileName, extension='$fileExtension', watchPath=$watchPath")
if (watchExtensions.contains(fileExtension)) {
val fullPath = Paths.get(watchPath).resolve(fileName).absolutePathString()
logger.info("New screenshot file created: $fullPath (extension='$fileExtension')")
showImageAttachmentNotification(project, fullPath)
} else {
logger.trace("File extension '$fileExtension' not in watch list: $watchExtensions")
}
}
} else {
logger.warn("No valid screenshot watch paths found - screenshot detection disabled")
}
}
}

View file

@ -2,42 +2,84 @@ package ee.carlrobert.codegpt
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.thisLogger
import java.nio.file.FileSystems
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE
import java.nio.file.WatchService
import kotlin.concurrent.thread
import kotlin.io.path.exists
@Service(Service.Level.PROJECT)
class FileWatcher : Disposable {
private var fileMonitor: Thread? = null
private val watchServices = mutableListOf<WatchService>()
private val fileMonitors = mutableListOf<Thread>()
private val logger = thisLogger()
fun watch(pathToWatch: Path, onFileCreated: (Path) -> Unit) {
fileMonitor = pathToWatch.takeIf { it.exists() }?.let {
fun watchMultiplePaths(pathsToWatch: List<String>, onFileCreated: (Path, String) -> Unit) {
dispose()
pathsToWatch.forEach { pathString ->
try {
FileSystems.getDefault().newWatchService().also {
pathToWatch.register(it, ENTRY_CREATE) // watch for new files
val path = Paths.get(pathString)
if (path.exists()) {
val watchService = FileSystems.getDefault().newWatchService()
path.register(watchService, ENTRY_CREATE)
watchServices.add(watchService)
logger.debug("Successfully registered watch service for path: $pathString (absolute: ${path.toAbsolutePath()})")
val monitor = thread {
try {
logger.debug("File watch monitor thread started for path: $pathString")
generateSequence { watchService.take() }.forEach { key ->
logger.trace("Watch event received for path: $pathString")
key.pollEvents().forEach { event ->
val fileName = event.context() as Path
val fullPath = path.resolve(fileName)
logger.debug("File event detected: ${event.kind()} - fileName=$fileName, fullPath=$fullPath")
onFileCreated(fileName, pathString)
}
val resetResult = key.reset()
if (!resetResult) {
logger.warn("Watch key reset failed for path: $pathString - watch may have become invalid")
}
}
} catch (e: InterruptedException) {
logger.debug("File watch monitor thread interrupted for path: $pathString")
Thread.currentThread().interrupt()
} catch (e: Exception) {
logger.warn("Error in file watcher for path: $pathString", e)
} finally {
logger.debug("File watch monitor thread stopped for path: $pathString")
}
}
fileMonitors.add(monitor)
logger.info("Started watching path: $pathString")
} else {
logger.warn("Path does not exist or is not accessible: $pathString")
}
} catch (e: Exception) {
null // WatchService or registration failed
}
}?.let { watchService ->
thread {
try {
generateSequence { watchService.take() }.forEach { key ->
key.pollEvents().forEach { onFileCreated(it.context() as Path) }
key.reset()
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
logger.warn("Failed to set up watcher for path: $pathString", e)
}
}
}
override fun dispose() {
fileMonitor?.interrupt()
logger.debug("Disposing FileWatcher - stopping ${fileMonitors.size} monitor threads and ${watchServices.size} watch services")
fileMonitors.forEach { it.interrupt() }
fileMonitors.clear()
watchServices.forEach { watchService ->
try {
watchService.close()
} catch (e: Exception) {
logger.warn("Error closing watch service", e)
}
}
watchServices.clear()
logger.debug("FileWatcher disposal completed")
}
}

View file

@ -27,6 +27,7 @@ class ConfigurationSettingsState : BaseState() {
var temperature by property(0.1f) { max(0f, min(1f, it)) }
var checkForPluginUpdates by property(true)
var checkForNewScreenshots by property(true)
var screenshotWatchPaths by list<String>()
var ignoreGitCommitTokenLimit by property(false)
var methodNameGenerationEnabled by property(true)
var captureCompileErrors by property(true)
@ -34,9 +35,14 @@ class ConfigurationSettingsState : BaseState() {
var tableData by map<String, String>()
var chatCompletionSettings by property(ChatCompletionSettingsState())
var codeCompletionSettings by property(CodeCompletionSettingsState())
var myAwesomeFeatureEnabled by property(false)
init {
tableData.putAll(EditorActionsUtil.DEFAULT_ACTIONS)
if (screenshotWatchPaths.isEmpty()) {
screenshotWatchPaths.addAll(ScreenshotPathDetector.getDefaultPaths())
}
}
}

View file

@ -0,0 +1,34 @@
package ee.carlrobert.codegpt.settings.configuration
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.TextFieldWithBrowseButton
import com.intellij.util.ui.FormBuilder
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.CodeGPTBundle
import java.awt.Dimension
import javax.swing.JComponent
class PathInputDialog(
title: String,
private val textField: TextFieldWithBrowseButton
) : DialogWrapper(true) {
init {
this.title = title
init()
}
override fun createCenterPanel(): JComponent {
val panel = FormBuilder.createFormBuilder()
.addLabeledComponent(
CodeGPTBundle.get("configurationConfigurable.screenshotPaths.dialog.path.label"),
textField,
true
)
.panel
panel.preferredSize = Dimension(JBUI.scale(500), panel.preferredSize.height)
return panel
}
override fun getPreferredFocusedComponent(): JComponent = textField
}

View file

@ -0,0 +1,135 @@
package ee.carlrobert.codegpt.settings.configuration
import com.intellij.openapi.fileChooser.FileChooserDescriptor
import com.intellij.openapi.ui.TextBrowseFolderListener
import com.intellij.openapi.ui.TextFieldWithBrowseButton
import com.intellij.ui.ToolbarDecorator
import com.intellij.ui.components.JBList
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.panel
import ee.carlrobert.codegpt.CodeGPTBundle
import java.awt.BorderLayout
import javax.swing.DefaultListModel
import javax.swing.JPanel
import javax.swing.ListSelectionModel
class ScreenshotConfigurationForm {
private val pathListModel = DefaultListModel<String>()
private val pathList = JBList(pathListModel)
init {
pathList.selectionMode = ListSelectionModel.SINGLE_SELECTION
pathList.visibleRowCount = 3
}
fun createPanel(): JPanel {
val pathPanel = createPathConfigurationPanel()
return panel {
row {
label(CodeGPTBundle.get("configurationConfigurable.screenshotPaths.label"))
}
row {
cell(pathPanel)
.align(AlignX.FILL)
.resizableColumn()
.comment(CodeGPTBundle.get("configurationConfigurable.screenshotPaths.comment"))
}
}
}
private fun createPathConfigurationPanel(): JPanel {
val panel = JPanel(BorderLayout())
val decorator = ToolbarDecorator.createDecorator(pathList)
.setAddAction { addPath() }
.setRemoveAction { removePath() }
.setEditAction { editPath() }
.setAddActionName(CodeGPTBundle.get("configurationConfigurable.screenshotPaths.add"))
.setRemoveActionName(CodeGPTBundle.get("configurationConfigurable.screenshotPaths.remove"))
.setEditActionName(CodeGPTBundle.get("configurationConfigurable.screenshotPaths.edit"))
panel.add(decorator.createPanel(), BorderLayout.CENTER)
return panel
}
private fun addPath() {
val textField = TextFieldWithBrowseButton()
val fileChooserDescriptor = FileChooserDescriptor(false, true, false, false, false, false)
fileChooserDescriptor.title =
CodeGPTBundle.get("configurationConfigurable.screenshotPaths.chooser.title")
fileChooserDescriptor.description =
CodeGPTBundle.get("configurationConfigurable.screenshotPaths.chooser.description")
textField.addBrowseFolderListener(
TextBrowseFolderListener(fileChooserDescriptor)
)
val dialog = PathInputDialog(
CodeGPTBundle.get("configurationConfigurable.screenshotPaths.add.title"),
textField
)
if (dialog.showAndGet()) {
val path = textField.text
if (path.isNotBlank() && !pathListModel.contains(path)) {
pathListModel.addElement(path)
}
}
}
private fun removePath() {
val selectedIndex = pathList.selectedIndex
if (selectedIndex >= 0) {
pathListModel.removeElementAt(selectedIndex)
}
}
private fun editPath() {
val selectedIndex = pathList.selectedIndex
if (selectedIndex >= 0) {
val currentPath = pathListModel.getElementAt(selectedIndex)
val textField = TextFieldWithBrowseButton()
textField.text = currentPath
val fileChooserDescriptor =
FileChooserDescriptor(false, true, false, false, false, false)
fileChooserDescriptor.title =
CodeGPTBundle.get("configurationConfigurable.screenshotPaths.chooser.title")
fileChooserDescriptor.description =
CodeGPTBundle.get("configurationConfigurable.screenshotPaths.chooser.description")
textField.addBrowseFolderListener(
TextBrowseFolderListener(fileChooserDescriptor)
)
val dialog = PathInputDialog(
CodeGPTBundle.get("configurationConfigurable.screenshotPaths.edit.title"),
textField
)
if (dialog.showAndGet()) {
val newPath = textField.text
if (newPath.isNotBlank()) {
pathListModel.setElementAt(newPath, selectedIndex)
}
}
}
}
fun loadState(paths: List<String>) {
pathListModel.clear()
paths.forEach { pathListModel.addElement(it) }
}
fun getState(): List<String> {
val paths = mutableListOf<String>()
for (i in 0 until pathListModel.size()) {
paths.add(pathListModel.getElementAt(i))
}
return paths
}
}

View file

@ -0,0 +1,89 @@
package ee.carlrobert.codegpt.settings.configuration
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.util.SystemInfo
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.exists
object ScreenshotPathDetector {
private val logger = thisLogger()
fun getDefaultPaths(): List<String> {
return when {
SystemInfo.isWindows -> getWindowsDefaultPaths()
SystemInfo.isMac -> getMacDefaultPaths()
SystemInfo.isLinux -> getLinuxDefaultPaths()
else -> listOf(getDesktopPath())
}
}
fun isValidWatchPath(pathString: String): Boolean {
return try {
val path = Paths.get(pathString)
path.exists() && path.toFile().canRead()
} catch (e: Exception) {
logger.warn("Error validating watch path: $pathString", e)
false
}
}
fun getDefaultFileExtensions(): List<String> {
return listOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
}
private fun getWindowsDefaultPaths(): List<String> {
val paths = mutableListOf<String>()
paths.add(getDesktopPath())
val screenshotsPath = Paths.get(System.getProperty("user.home"), "Pictures", "Screenshots")
if (screenshotsPath.exists()) {
paths.add(screenshotsPath.toString())
}
return paths
}
private fun getMacDefaultPaths(): List<String> {
val paths = mutableListOf<String>()
paths.add(getDesktopPath())
val picturesPath = Paths.get(System.getProperty("user.home"), "Pictures")
if (picturesPath.exists()) {
paths.add(picturesPath.toString())
}
return paths
}
private fun getLinuxDefaultPaths(): List<String> {
val paths = mutableListOf<String>()
paths.add(getDesktopPath())
val picturesPath = getXdgPicturesPath()
if (picturesPath.exists()) {
paths.add(picturesPath.toString())
}
val screenshotsPath = picturesPath.resolve("Screenshots")
if (screenshotsPath.exists()) {
paths.add(screenshotsPath.toString())
}
return paths
}
private fun getDesktopPath(): String {
return Paths.get(System.getProperty("user.home"), "Desktop").toString()
}
private fun getXdgPicturesPath(): Path {
val xdgPictures = System.getenv("XDG_PICTURES_DIR")
return if (xdgPictures != null) {
Paths.get(xdgPictures)
} else {
Paths.get(System.getProperty("user.home"), "Pictures")
}
}
}

View file

@ -120,6 +120,19 @@ configurationConfigurable.table.action.revertToDefaults.text=Revert to Defaults
configurationConfigurable.table.action.addKeymap.text=Add Shortcut
configurationConfigurable.checkForPluginUpdates.label=Check for plugin updates automatically
configurationConfigurable.checkForNewScreenshots.label=Check for new screenshots automatically
# Screenshot Configuration
configurationConfigurable.section.screenshots.title=Screenshot Detection
configurationConfigurable.screenshotPaths.label=Watch Paths:
configurationConfigurable.screenshotPaths.add=Add Path
configurationConfigurable.screenshotPaths.remove=Remove Path
configurationConfigurable.screenshotPaths.edit=Edit Path
configurationConfigurable.screenshotPaths.add.title=Add Screenshot Watch Path
configurationConfigurable.screenshotPaths.edit.title=Edit Screenshot Watch Path
configurationConfigurable.screenshotPaths.chooser.title=Select Directory to Watch for Screenshots
configurationConfigurable.screenshotPaths.chooser.description=Choose a directory where screenshot files will be detected
configurationConfigurable.screenshotPaths.dialog.path.label=Path:
configurationConfigurable.screenshotPaths.comment=Add directories where screenshots are typically saved. The plugin monitors these directories for new image files.
configurationConfigurable.openNewTabCheckBox.label=Open a new chat on each action
configurationConfigurable.enableMethodNameGeneration.label=Enable method name lookup suggestions
configurationConfigurable.autoFormatting.label=Enable automatic code formatting
@ -371,7 +384,7 @@ settings.models.chat.label=Chat:
settings.models.code.label=Autocomplete:
settings.models.autoApply.label=Auto apply:
settings.models.commitMessages.label=Commit messages:
settings.models.editCode.label=Edit code:
settings.models.inlineEdit.label=Inline edit:
settings.models.nextEdit.label=Next edits:
settings.models.nameLookups.label=Name lookups:
settings.models.selectModel=Select a model

View file

@ -120,6 +120,19 @@ configurationConfigurable.table.action.revertToDefaults.text=\u6062\u590D\u9ED8\
configurationConfigurable.table.action.addKeymap.text=\u6DFB\u52A0\u5FEB\u6377\u952E
configurationConfigurable.checkForPluginUpdates.label=\u81EA\u52A8\u68C0\u67E5\u63D2\u4EF6\u66F4\u65B0
configurationConfigurable.checkForNewScreenshots.label=\u81EA\u52A8\u68C0\u67E5\u65B0\u622A\u56FE
# Screenshot Configuration
configurationConfigurable.section.screenshots.title=\u622A\u56FE\u68C0\u6D4B
configurationConfigurable.screenshotPaths.label=\u76D1\u89C6\u8DEF\u5F84:
configurationConfigurable.screenshotPaths.add=\u6DFB\u52A0\u8DEF\u5F84
configurationConfigurable.screenshotPaths.remove=\u5220\u9664\u8DEF\u5F84
configurationConfigurable.screenshotPaths.edit=\u7F16\u8F91\u8DEF\u5F84
configurationConfigurable.screenshotPaths.add.title=\u6DFB\u52A0\u622A\u56FE\u76D1\u89C6\u8DEF\u5F84
configurationConfigurable.screenshotPaths.edit.title=\u7F16\u8F91\u622A\u56FE\u76D1\u89C6\u8DEF\u5F84
configurationConfigurable.screenshotPaths.chooser.title=\u9009\u62E9\u76D1\u89C6\u622A\u56FE\u7684\u76EE\u5F55
configurationConfigurable.screenshotPaths.chooser.description=\u9009\u62E9\u4E00\u4E2A\u7528\u4E8E\u68C0\u6D4B\u622A\u56FE\u6587\u4EF6\u7684\u76EE\u5F55
configurationConfigurable.screenshotPaths.dialog.path.label=\u8DEF\u5F84:
configurationConfigurable.screenshotPaths.comment=\u6DFB\u52A0\u901A\u5E38\u4FDD\u5B58\u622A\u56FE\u7684\u76EE\u5F55\u3002\u63D2\u4EF6\u4F1A\u76D1\u89C6\u8FD9\u4E9B\u76EE\u5F55\u4E2D\u7684\u65B0\u56FE\u50CF\u6587\u4EF6\u3002
configurationConfigurable.openNewTabCheckBox.label=\u6BCF\u4E2A\u64CD\u4F5C\u6253\u5F00\u65B0\u804A\u5929\u6807\u7B7E
configurationConfigurable.enableMethodNameGeneration.label=\u542F\u7528\u65B9\u6CD5\u540D\u79F0\u67E5\u627E\u5EFA\u8BAE
configurationConfigurable.autoFormatting.label=\u542F\u7528\u81EA\u52A8\u4EE3\u7801\u683C\u5F0F\u5316
@ -370,7 +383,7 @@ settings.models.chat.label=\u804A\u5929:
settings.models.code.label=\u81EA\u52A8\u8865\u5168:
settings.models.autoApply.label=\u81EA\u52A8\u5E94\u7528:
settings.models.commitMessages.label=\u63D0\u4EA4\u6D88\u606F:
settings.models.editCode.label=\u7F16\u8F91\u4EE3\u7801:
settings.models.inlineEdit.label=\u7F16\u8F91\u4EE3\u7801:
settings.models.nextEdit.label=\u4E0B\u4E00\u6B65\u7F16\u8F91:
settings.models.nameLookups.label=\u540D\u79F0\u67E5\u627E:
settings.models.selectModel=\u9009\u62E9\u6A21\u578B