From 248682cc5dba7362026b27062ad6b89048e449c2 Mon Sep 17 00:00:00 2001 From: Henrik Mertens Date: Wed, 22 Apr 2026 00:31:37 +0200 Subject: [PATCH 01/11] Implement UTF-16 offset support and add context chunk limiting Updated the NES engine and IntelliJ plugin to use UTF-16 offsets for more accurate cursor positioning and edit resolution. Introduced a toggle for changes-above-cursor mode and implemented logic to limit context chunks for local base URLs. - **Core**: Added `utf16_offset_to_byte_offset` and `resolve_edit_start_offset` to handle cross-platform coordinate mapping. - **Core**: Enhanced `compute_prefill` with `force_ghost_text` support and improved line break counting for CRLF compatibility. - **Core**: Updated `build_sweep_prompt` to support optional context chunk capping and forced prefixes. - **IntelliJ**: Added `nesChangesAboveCursorEnabled` setting and UI toggle in `OxideCodeConfigurable`. - **IntelliJ**: Updated `NesEditorListener` to pass `cursorOffsetUtf16` and conditional `limitContextChunks` based on the base URL. - **JNI/Node**: Updated bindings to include UTF-16 offset and context limit parameters. --- bindings/jvm/src/lib.rs | 6 + bindings/node/src/lib.rs | 2 + core/src/nes/engine.rs | 61 +++++--- core/src/nes/prompt.rs | 131 +++++++++++++++--- .../main/kotlin/com/oxidecode/CoreBridge.kt | 2 + .../com/oxidecode/nes/NesEditorListener.kt | 14 ++ .../com/oxidecode/nes/NesSessionTracker.kt | 6 +- .../settings/OxideCodeConfigurable.kt | 5 + .../oxidecode/settings/OxideCodeSettings.kt | 5 + 9 files changed, 196 insertions(+), 36 deletions(-) diff --git a/bindings/jvm/src/lib.rs b/bindings/jvm/src/lib.rs index 61f223b..8503615 100644 --- a/bindings/jvm/src/lib.rs +++ b/bindings/jvm/src/lib.rs @@ -221,6 +221,8 @@ pub extern "system" fn Java_com_oxidecode_CoreBridge_predictNextEdit( cursor_filepath: JString, cursor_line: jint, cursor_col: jint, + cursor_offset_utf16: jint, + limit_context_chunks: jboolean, file_content: JString, language: JString, completion_endpoint: JString, @@ -293,9 +295,11 @@ pub extern "system" fn Java_com_oxidecode_CoreBridge_predictNextEdit( filepath = %cursor_filepath, line = cursor_line, col = cursor_col, + cursor_offset_utf16 = cursor_offset_utf16, language = %language, prompt_style = ?prompt_style, endpoint = ?endpoint, + limit_context_chunks = (limit_context_chunks != 0), "Java_com_oxidecode_CoreBridge_predictNextEdit called" ); @@ -324,6 +328,7 @@ pub extern "system" fn Java_com_oxidecode_CoreBridge_predictNextEdit( &cursor_filepath, cursor_line as u32, cursor_col as u32, + cursor_offset_utf16 as u32, &file_content, &language, original_file_content_opt, @@ -341,6 +346,7 @@ pub extern "system" fn Java_com_oxidecode_CoreBridge_predictNextEdit( }, Some(&high_res_deltas), changes_above_cursor != 0, + limit_context_chunks != 0, cancel, )); diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 42fb3bf..a4af297 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -267,6 +267,7 @@ pub async fn predict_next_edit( &cursor_filepath, cursor_line, cursor_col, + 0, &file_content, &language, original_file_content.as_deref(), @@ -276,6 +277,7 @@ pub async fn predict_next_edit( None, None, false, + false, cancel, ) .await; diff --git a/core/src/nes/engine.rs b/core/src/nes/engine.rs index 218922c..0d41761 100644 --- a/core/src/nes/engine.rs +++ b/core/src/nes/engine.rs @@ -285,6 +285,28 @@ fn byte_offset_for_line_col(text: &str, line: u32, col: u32) -> usize { offset.min(text.len()) } +fn utf16_offset_to_byte_offset(text: &str, utf16_offset: usize) -> usize { + let mut units = 0usize; + let mut bytes = 0usize; + for ch in text.chars() { + let next_units = units + ch.len_utf16(); + if next_units > utf16_offset { + break; + } + units = next_units; + bytes += ch.len_utf8(); + } + bytes.min(text.len()) +} + +fn resolve_edit_start_offset(text: &str, edit: &EditDelta) -> usize { + if let Some(start_offset_utf16) = edit.start_offset { + utf16_offset_to_byte_offset(text, start_offset_utf16) + } else { + byte_offset_for_line_col(text, edit.start_line, edit.start_col) + } +} + fn build_unified_diff_like_original(original_text: &str, new_text: &str) -> String { let original_lines: Vec<&str> = original_text.lines().collect(); let new_lines: Vec<&str> = new_text.lines().collect(); @@ -814,6 +836,7 @@ impl NesEngine { cursor_filepath: &str, cursor_line: u32, cursor_col: u32, + cursor_offset_utf16: u32, file_content: &str, language: &str, original_file_content: Option<&str>, @@ -823,6 +846,7 @@ impl NesEngine { high_res_history_prompt: Option<&str>, high_res_edits: Option<&[EditDelta]>, changes_above_cursor: bool, + limit_context_chunks: bool, cancel: CancellationToken, ) -> Option { let recent_edits: Vec = { @@ -889,6 +913,7 @@ impl NesEngine { cursor_filepath, cursor_line, cursor_col, + cursor_offset_utf16, file_content, orig, history_prompt, @@ -896,6 +921,7 @@ impl NesEngine { retrieval_chunks, high_res_history_prompt, changes_above_cursor, + limit_context_chunks, cancel, ) .await @@ -1082,6 +1108,7 @@ impl NesEngine { cursor_filepath: &str, cursor_line: u32, cursor_col: u32, + cursor_offset_utf16: u32, file_content: &str, original_file_content: &str, history_prompt: Option<&str>, @@ -1089,6 +1116,7 @@ impl NesEngine { retrieval_chunks: Option<&[FileChunk]>, high_res_history_prompt: Option<&str>, changes_above_cursor: bool, + limit_context_chunks: bool, cancel: CancellationToken, ) -> Option { let history_from_prompt = history_prompt.is_some(); @@ -1100,8 +1128,7 @@ impl NesEngine { .rev() .filter_map(|edit| { let text = &edit.file_content; - let start_offset = - byte_offset_for_line_col(text, edit.start_line, edit.start_col); + let start_offset = resolve_edit_start_offset(text, edit); let after_end_calc = (start_offset + edit.inserted.len()).min(text.len()); let is_after_state = text.get(start_offset..after_end_calc) == Some(edit.inserted.as_str()); @@ -1145,17 +1172,17 @@ impl NesEngine { } // ── 2. Compute cursor byte offset from (line, col) ────────────── - let cursor_position = { - let mut offset = 0usize; - for (i, line) in file_content.split_inclusive('\n').enumerate() { - if i == cursor_line as usize { - let visible_len = line.strip_suffix('\n').map_or(line.len(), str::len); - offset += (cursor_col as usize).min(visible_len); - break; - } - offset += line.len(); - } - offset + let cursor_position = utf16_offset_to_byte_offset(file_content, cursor_offset_utf16 as usize); + + let capped_file_chunks = if limit_context_chunks { + file_chunks.map(|chunks| &chunks[..chunks.len().min(1)]) + } else { + file_chunks + }; + let capped_retrieval_chunks = if limit_context_chunks { + retrieval_chunks.map(|chunks| &chunks[..chunks.len().min(1)]) + } else { + retrieval_chunks }; // ── 3. Call the new build_sweep_prompt ──────────────────────────── @@ -1169,8 +1196,7 @@ impl NesEngine { .rev() .filter_map(|edit| { let text = &edit.file_content; - let start_offset = - byte_offset_for_line_col(text, edit.start_line, edit.start_col); + let start_offset = resolve_edit_start_offset(text, edit); let after_end_calc = (start_offset + edit.inserted.len()).min(text.len()); let is_after_state = text.get(start_offset..after_end_calc) == Some(edit.inserted.as_str()); @@ -1220,9 +1246,10 @@ impl NesEngine { cursor_position, &recent_changes, Some(&high_res_recent_changes), - retrieval_chunks, - file_chunks, + capped_retrieval_chunks, + capped_file_chunks, changes_above_cursor, + !limit_context_chunks, None, // num_lines_before None, // num_lines_after ); diff --git a/core/src/nes/prompt.rs b/core/src/nes/prompt.rs index b1423f0..8026886 100644 --- a/core/src/nes/prompt.rs +++ b/core/src/nes/prompt.rs @@ -1027,11 +1027,27 @@ impl SplitlinesKeepTerminator for str { fn splitlines_keep_terminator(&self) -> Vec<&str> { let mut lines = Vec::new(); let mut start = 0; - for (i, b) in self.bytes().enumerate() { - if b == b'\n' { - lines.push(&self[start..=i]); - start = i + 1; + let bytes = self.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b'\n' { + let end = i + 1; + lines.push(&self[start..end]); + start = end; + i = end; + continue; } + if bytes[i] == b'\r' { + let mut end = i + 1; + if i + 1 < bytes.len() && bytes[i + 1] == b'\n' { + end = i + 2; + } + lines.push(&self[start..end]); + start = end; + i = end; + continue; + } + i += 1; } if start < self.len() { lines.push(&self[start..]); @@ -1166,7 +1182,83 @@ fn apply_recent_changes_to_section( // ─── compute_prefill ───────────────────────────────────────────────────────── -fn compute_prefill(code_block: &str, relative_cursor: usize, changes_above_cursor: bool) -> String { +fn count_line_breaks(text: &str) -> usize { + let bytes = text.as_bytes(); + let mut i = 0usize; + let mut count = 0usize; + while i < bytes.len() { + if bytes[i] == b'\n' { + count += 1; + i += 1; + continue; + } + if bytes[i] == b'\r' { + count += 1; + if i + 1 < bytes.len() && bytes[i + 1] == b'\n' { + i += 2; + } else { + i += 1; + } + continue; + } + i += 1; + } + count +} + +fn split_prefill_for_forced_prefix(prefill: &str) -> (String, String) { + if prefill.is_empty() { + return (String::new(), String::new()); + } + + #[derive(Copy, Clone, Eq, PartialEq)] + enum CharClass { + Whitespace, + Word, + Other, + } + + fn classify(ch: char) -> CharClass { + if ch.is_whitespace() { + CharClass::Whitespace + } else if ch.is_alphanumeric() || ch == '_' { + CharClass::Word + } else { + CharClass::Other + } + } + + let mut start: usize; + let mut chars = prefill.char_indices().rev(); + let Some((last_idx, last_ch)) = chars.next() else { + return (String::new(), String::new()); + }; + let last_class = classify(last_ch); + start = last_idx; + + for (idx, ch) in chars { + if classify(ch) != last_class { + break; + } + start = idx; + } + + (prefill[..start].to_string(), prefill[start..].to_string()) +} + +fn compute_prefill( + code_block: &str, + relative_cursor: usize, + changes_above_cursor: bool, + force_ghost_text: bool, +) -> (String, String) { + let is_at_eof = relative_cursor == code_block.len(); + if force_ghost_text && !is_at_eof { + let pre_cursor = &code_block[..relative_cursor]; + let (prefill, forced_prefix) = split_prefill_for_forced_prefix(pre_cursor); + return (prefill, forced_prefix); + } + if changes_above_cursor { let pre_cursor = &code_block[..relative_cursor]; let lines: Vec<&str> = pre_cursor.splitlines_keep_terminator(); @@ -1181,10 +1273,9 @@ fn compute_prefill(code_block: &str, relative_cursor: usize, changes_above_curso .copied() .collect(); let leading_newlines = after_split.len() - after_split.trim_start_matches('\n').len(); - format!("{}{}", before_split, "\n".repeat(leading_newlines)) + (format!("{}{}", before_split, "\n".repeat(leading_newlines)), String::new()) } else { - // Python: `else: prefill = ""; forced_prefix = ""` - String::new() + (String::new(), String::new()) } } @@ -1239,6 +1330,7 @@ pub struct SweepPromptContext { /// Text prepended to the model's output (everything before the cursor up /// to — and including — the last newline in that region). pub prefill: String, + pub forced_prefix: String, /// 1-indexed start line used in the prompt `original/` / `current/` / /// `updated/` section headers. pub cursor_line_start: u32, @@ -1326,10 +1418,7 @@ fn get_lines_around_cursor(file_contents: &str, cursor_position: usize) -> Strin } // 0-indexed line number for cursor_position. - let cursor_line = file_contents[..cursor_position.min(file_contents.len())] - .bytes() - .filter(|&b| b == b'\n') - .count(); + let cursor_line = count_line_breaks(&file_contents[..cursor_position.min(file_contents.len())]); // Nearest stride-aligned chunk index. let ideal_start = cursor_line as i64 - (CHUNK_SIZE / 2) as i64; @@ -1389,6 +1478,7 @@ pub fn build_sweep_prompt( retrieval_chunks: Option<&[FileChunk]>, file_chunks: Option<&[FileChunk]>, changes_above_cursor: bool, + force_ghost_text: bool, num_lines_before: Option, num_lines_after: Option, ) -> (String, SweepPromptContext) { @@ -1405,6 +1495,7 @@ pub fn build_sweep_prompt( block_start_offset: 0, block_start_line: 0, prefill: String::new(), + forced_prefix: String::new(), cursor_line_start: 0, cursor_line_end: 0, relative_cursor_offset: 0, @@ -1467,7 +1558,8 @@ pub fn build_sweep_prompt( ); // 7. Compute prefill. - let prefill = compute_prefill(&code_block, relative_cursor_offset, changes_above_cursor); + let (prefill, forced_prefix) = + compute_prefill(&code_block, relative_cursor_offset, changes_above_cursor, force_ghost_text); // 8. Build the broad context (lines around cursor, full file or 300-line chunk). let has_retrieval = retrieval_chunks.map_or(false, |r| !r.is_empty()); @@ -1522,10 +1614,7 @@ pub fn build_sweep_prompt( // relative_cursor_line = number of newlines before the cursor in the block (0-indexed) // start_line = relative_cursor_line + 1 // end_line = relative_cursor_line + len(code_block.splitlines()) + 1 - let relative_cursor_line = code_block[..relative_cursor_offset] - .bytes() - .filter(|&b| b == b'\n') - .count(); + let relative_cursor_line = count_line_breaks(&code_block[..relative_cursor_offset]); let start_line = (relative_cursor_line as u32) + 1; let end_line = (relative_cursor_line + code_block.lines().count() + 1) as u32; @@ -1625,6 +1714,7 @@ pub fn build_sweep_prompt( block_start_offset, block_start_line: block_start as u32, prefill, + forced_prefix, cursor_line_start: start_line, cursor_line_end: end_line, relative_cursor_offset, @@ -1644,7 +1734,12 @@ pub fn parse_sweep_response(raw: &str, ctx: &SweepPromptContext) -> Option { var nesEnabled: Boolean = true, var nesDebounceMs: Int = 300, var nesPromptStyle: String = "sweep", + var nesChangesAboveCursorEnabled: Boolean = false, /** "completions" → /v1/completions (default); "chat_completions" → /v1/chat/completions */ var completionEndpoint: String = "completions", /** When non-empty, NES predictions are logged as JSONL to this directory. */ @@ -71,6 +72,10 @@ class OxideCodeSettings : PersistentStateComponent { get() = state.nesPromptStyle set(v) { state = state.copy(nesPromptStyle = v) } + var nesChangesAboveCursorEnabled: Boolean + get() = state.nesChangesAboveCursorEnabled + set(v) { state = state.copy(nesChangesAboveCursorEnabled = v) } + var completionEndpoint: String get() = state.completionEndpoint set(v) { state = state.copy(completionEndpoint = v) } From 881b8400ca1a692a3f402fbd0151a28ec13a2c3f Mon Sep 17 00:00:00 2001 From: Henrik Mertens Date: Wed, 22 Apr 2026 00:34:29 +0200 Subject: [PATCH 02/11] bump v --- intellij-plugin/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/intellij-plugin/build.gradle.kts b/intellij-plugin/build.gradle.kts index 6ba9e91..744e8e1 100644 --- a/intellij-plugin/build.gradle.kts +++ b/intellij-plugin/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.oxidecode" -version = "0.3.3" +version = "0.4.0" repositories { mavenCentral() From e9cf1f9c17fb64f99cdfc4014d5847fdec3be415 Mon Sep 17 00:00:00 2001 From: Henrik Mertens Date: Wed, 22 Apr 2026 23:57:47 +0200 Subject: [PATCH 03/11] add reduced sweep intellij plugin --- intellij-plugin-v2/build.gradle.kts | 89 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + intellij-plugin-v2/gradlew | 251 ++ intellij-plugin-v2/gradlew.bat | 94 + .../AutocompleteHighlightingUtils.kt | 71 + .../autocomplete/AutocompleteUtils.kt | 55 + .../edit/AutocompleteImportDetector.kt | 810 +++++ .../edit/AutocompleteRejectionCache.kt | 133 + .../edit/AutocompleteSuggestion.kt | 1070 +++++++ .../ClearAutocompleteRejectionCacheAction.kt | 36 + .../edit/EditAutocompleteActions.kt | 95 + .../edit/EditAutocompleteModels.kt | 170 ++ .../edit/EditAutocompleteUtils.kt | 144 + .../edit/EditorActionsRouterService.kt | 386 +++ .../edit/EntityUsageSearchService.kt | 771 +++++ .../autocomplete/edit/GhostTextRenderer.kt | 1345 ++++++++ .../autocomplete/edit/JumpHintManager.kt | 381 +++ .../edit/KeystrokeToEditorActionMapper.kt | 93 + .../autocomplete/edit/LookupUICustomizer.kt | 118 + .../autocomplete/edit/RecentEditsTracker.kt | 2481 +++++++++++++++ .../oxidecode/components/TruncatedLabel.kt | 126 + .../controllers/OxideCodeGhostText.kt | 431 +++ .../kotlin/com/oxidecode/data/IDEVersion.kt | 96 + .../main/kotlin/com/oxidecode/data/Models.kt | 720 +++++ .../com/oxidecode/data/RecentFilesBase.kt | 37 + .../com/oxidecode/data/RecentlyUsedFiles.kt | 64 + .../com/oxidecode/data/SelectedSnippet.kt | 37 + .../com/oxidecode/listener/FileActions.kt | 13 + .../listener/SelectedFileChangeListener.kt | 59 + .../services/AutocompleteIpResolverService.kt | 250 ++ .../services/ClipboardTrackingService.kt | 113 + .../oxidecode/services/CodeEntityExtractor.kt | 412 +++ .../com/oxidecode/services/FileSearcher.kt | 385 +++ .../oxidecode/services/FileUsageManager.kt | 43 + .../services/GitIndexCleanupService.kt | 134 + .../services/IdeaVimIntegrationService.kt | 178 ++ .../NotificationDeduplicationService.kt | 299 ++ .../services/OxideCodeColorChangeService.kt | 50 + .../services/OxideCodeConstantsService.kt | 31 + .../OxideCodeNonProjectFilesService.kt | 86 + .../services/OxideCodeProblemRetriever.kt | 52 + .../services/OxideCodeProjectService.kt | 19 + .../oxidecode/services/SessionMessageList.kt | 330 ++ .../oxidecode/services/StreamStateService.kt | 54 + .../oxidecode/settings/OxideCodeMetaData.kt | 562 ++++ .../oxidecode/settings/OxideCodeSettings.kt | 241 ++ .../com/oxidecode/theme/EditorThemeManager.kt | 283 ++ .../com/oxidecode/theme/OxideCodeColors.kt | 341 +++ .../com/oxidecode/theme/OxideCodeIcons.kt | 48 + .../theme/RoundedHighlightPainter.kt | 47 + .../kotlin/com/oxidecode/utils/ActionUtils.kt | 89 + .../com/oxidecode/utils/CompressionUtils.kt | 101 + .../oxidecode/utils/DatabaseOperationQueue.kt | 128 + .../kotlin/com/oxidecode/utils/DiffManager.kt | 133 + .../kotlin/com/oxidecode/utils/DiffUtils.kt | 589 ++++ .../kotlin/com/oxidecode/utils/EditorUtils.kt | 644 ++++ .../kotlin/com/oxidecode/utils/Extensions.kt | 512 ++++ .../com/oxidecode/utils/FileDisplayUtils.kt | 41 + .../com/oxidecode/utils/FileSearchUtils.kt | 108 + .../kotlin/com/oxidecode/utils/FileUtils.kt | 194 ++ .../kotlin/com/oxidecode/utils/FontUtils.kt | 31 + .../kotlin/com/oxidecode/utils/GithubUtils.kt | 480 +++ .../com/oxidecode/utils/HighlightingUtils.kt | 66 + .../kotlin/com/oxidecode/utils/LRUCache.kt | 222 ++ .../com/oxidecode/utils/OxideCodeBundle.kt | 27 + .../com/oxidecode/utils/OxideCodeConstants.kt | 2705 +++++++++++++++++ .../oxidecode/utils/PluginConflictUtils.kt | 143 + .../com/oxidecode/utils/ReflectionUtils.kt | 40 + .../com/oxidecode/utils/RequestUtils.kt | 186 ++ .../com/oxidecode/utils/StringDistance.kt | 129 + .../kotlin/com/oxidecode/utils/StringUtils.kt | 697 +++++ .../kotlin/com/oxidecode/utils/TabUtils.kt | 137 + .../com/oxidecode/utils/TranslucentIcon.kt | 36 + .../main/kotlin/com/oxidecode/utils/Utils.kt | 566 ++++ .../com/oxidecode/views/BlockDisplay.kt | 27 + .../kotlin/com/oxidecode/views/ChipPanel.kt | 395 +++ .../com/oxidecode/views/CircularLabel.kt | 27 + .../kotlin/com/oxidecode/views/Darkenable.kt | 27 + .../com/oxidecode/views/DarkenableLabel.kt | 16 + .../views/EditorComponentInlaysManager.kt | 207 ++ .../com/oxidecode/views/HighlightedBlock.kt | 429 +++ .../kotlin/com/oxidecode/views/Hoverable.kt | 31 + .../oxidecode/views/PopupEditorComponent.kt | 1018 +++++++ .../com/oxidecode/views/RoundedButton.kt | 392 +++ .../com/oxidecode/views/RoundedPanel.kt | 290 ++ .../com/oxidecode/views/RoundedTextArea.kt | 1315 ++++++++ .../com/oxidecode/views/ToggleButton.kt | 130 + .../src/main/resources/META-INF/plugin.xml | 52 + .../main/resources/META-INF/pluginIcon.svg | 1 + .../resources/META-INF/pluginIcon_dark.svg | 1 + 91 files changed, 26503 insertions(+) create mode 100644 intellij-plugin-v2/build.gradle.kts create mode 100644 intellij-plugin-v2/gradle/wrapper/gradle-wrapper.jar create mode 100644 intellij-plugin-v2/gradle/wrapper/gradle-wrapper.properties create mode 100644 intellij-plugin-v2/gradlew create mode 100644 intellij-plugin-v2/gradlew.bat create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/AutocompleteHighlightingUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/AutocompleteUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteImportDetector.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteRejectionCache.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteSuggestion.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/ClearAutocompleteRejectionCacheAction.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteActions.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteModels.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditorActionsRouterService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EntityUsageSearchService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/GhostTextRenderer.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/JumpHintManager.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/KeystrokeToEditorActionMapper.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/LookupUICustomizer.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/RecentEditsTracker.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/components/TruncatedLabel.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/controllers/OxideCodeGhostText.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/IDEVersion.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/Models.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/RecentFilesBase.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/RecentlyUsedFiles.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/SelectedSnippet.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/listener/FileActions.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/listener/SelectedFileChangeListener.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/AutocompleteIpResolverService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/ClipboardTrackingService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/CodeEntityExtractor.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/FileSearcher.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/FileUsageManager.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/GitIndexCleanupService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/IdeaVimIntegrationService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/NotificationDeduplicationService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeColorChangeService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeConstantsService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeNonProjectFilesService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeProblemRetriever.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeProjectService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/SessionMessageList.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/StreamStateService.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeMetaData.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeSettings.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/EditorThemeManager.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeColors.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeIcons.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/RoundedHighlightPainter.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/ActionUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/CompressionUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DatabaseOperationQueue.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DiffManager.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DiffUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/EditorUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/Extensions.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileDisplayUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileSearchUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FontUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/GithubUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/HighlightingUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/LRUCache.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeBundle.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeConstants.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/PluginConflictUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/ReflectionUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/RequestUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/StringDistance.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/StringUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/TabUtils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/TranslucentIcon.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/Utils.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/BlockDisplay.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/ChipPanel.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/CircularLabel.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/Darkenable.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/DarkenableLabel.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/EditorComponentInlaysManager.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/HighlightedBlock.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/Hoverable.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/PopupEditorComponent.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/RoundedButton.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/RoundedPanel.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/RoundedTextArea.kt create mode 100644 intellij-plugin-v2/src/main/kotlin/com/oxidecode/views/ToggleButton.kt create mode 100644 intellij-plugin-v2/src/main/resources/META-INF/plugin.xml create mode 100644 intellij-plugin-v2/src/main/resources/META-INF/pluginIcon.svg create mode 100644 intellij-plugin-v2/src/main/resources/META-INF/pluginIcon_dark.svg diff --git a/intellij-plugin-v2/build.gradle.kts b/intellij-plugin-v2/build.gradle.kts new file mode 100644 index 0000000..90123ca --- /dev/null +++ b/intellij-plugin-v2/build.gradle.kts @@ -0,0 +1,89 @@ +plugins { + id("java") + id("org.jetbrains.kotlin.jvm") version "2.3.20" + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" + id("org.jetbrains.intellij.platform") version "2.13.1" +} + +group = "com.oxidecode" +version = "0.4.0" + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdea("2025.1") + } + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + implementation("com.aayushatharva.brotli4j:brotli4j:1.16.0") + implementation("io.github.java-diff-utils:java-diff-utils:4.12") + implementation("org.eclipse.jgit:org.eclipse.jgit:7.6.0.202603022253-r") +} + +kotlin { + jvmToolchain(21) +} + +intellijPlatform { + pluginConfiguration { + ideaVersion { + sinceBuild = "241" + untilBuild = "261.*" + } + } +} + +tasks { + val nativePlatforms = listOf( + Triple("win32", "x64", "dll") to Pair("", "oxidecode_jvm_x64.dll"), + Triple("darwin", "arm64", "dylib") to Pair("lib", "oxidecode_jvm_arm64.dylib"), + Triple("darwin", "x64", "dylib") to Pair("lib", "oxidecode_jvm_x64.dylib"), + Triple("linux", "x64", "so") to Pair("lib", "oxidecode_jvm_x64.so"), + ) + + val copyNativeLibs by registering(Copy::class) { + nativePlatforms.forEach { (platform, mapping) -> + val (_, _, ext) = platform + val (prefix, destName) = mapping + from("${project.projectDir}/../target/release/${prefix}oxidecode_jvm.$ext") { + rename { destName } + } + } + into("${project.projectDir}/src/main/resources/native") + outputs.upToDateWhen { false } + } + + val copyNativeLib by registering(Copy::class) { + val os = System.getProperty("os.name").lowercase() + val arch = System.getProperty("os.arch").lowercase() + val ext = when { + os.contains("win") -> "dll" + os.contains("mac") -> "dylib" + else -> "so" + } + val prefix = if (os.contains("win")) "" else "lib" + val archTag = if (arch.contains("aarch64") || arch.contains("arm")) "arm64" else "x64" + + from("${project.projectDir}/../target/release/${prefix}oxidecode_jvm.$ext") + into("${project.projectDir}/src/main/resources/native") + rename { "oxidecode_jvm_${archTag}.$ext" } + + outputs.upToDateWhen { false } + } + + processResources { + if (!project.hasProperty("skipNativeCopy")) { + if (project.hasProperty("universal")) { + dependsOn(copyNativeLibs) + } else { + dependsOn(copyNativeLib) + } + } + } +} diff --git a/intellij-plugin-v2/gradle/wrapper/gradle-wrapper.jar b/intellij-plugin-v2/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/intellij-plugin-v2/gradle/wrapper/gradle-wrapper.properties b/intellij-plugin-v2/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2a84e18 --- /dev/null +++ b/intellij-plugin-v2/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/intellij-plugin-v2/gradlew b/intellij-plugin-v2/gradlew new file mode 100644 index 0000000..ef07e01 --- /dev/null +++ b/intellij-plugin-v2/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/intellij-plugin-v2/gradlew.bat b/intellij-plugin-v2/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/intellij-plugin-v2/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/AutocompleteHighlightingUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/AutocompleteHighlightingUtils.kt new file mode 100644 index 0000000..2ed2b25 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/AutocompleteHighlightingUtils.kt @@ -0,0 +1,71 @@ +@file:JvmName("AutocompleteHighlightingUtils") + +package com.oxidecode.autocomplete + +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.project.Project + +/** + * Adjusts the provided fullContext string based on the running IDE. + * + * Currently supported: + * - PhpStorm: Prepend " { + if (fullContext.trimStart().startsWith(" { + if (fullContext.trimStart().startsWith("package")) fullContext else "package test\n$fullContext" + } + else -> fullContext + } + } catch (e: Exception) { + // If anything goes wrong determining the IDE, return the original context unchanged + fullContext + } + +/** + * Determines whether we should run language annotators as part of semantic highlighting. + * + * Currently: + * - PhpStorm, PyCharm, DataGrip, CLion, RustRover, Android Studio, RubyMine, Rider, GoLand, WebStorm, IntelliJ: + * controlled by per-IDE feature flag "-run-annotators" (off by default if Project is null) + * - Others: true + * + * This will be expanded later with IDE-specific behavior. + */ +fun shouldRunAnnotatorsForSemanticHighlights(project: Project?): Boolean = + try { + val appName = ApplicationInfo.getInstance().fullApplicationName + val ideKey = + when { + appName.contains("PhpStorm", ignoreCase = true) -> "phpstorm" + appName.contains("PyCharm", ignoreCase = true) -> "pycharm" + appName.contains("DataGrip", ignoreCase = true) -> "datagrip" + appName.contains("CLion", ignoreCase = true) -> "clion" + appName.contains("RustRover", ignoreCase = true) -> "rustrover" + appName.contains("Android Studio", ignoreCase = true) -> "android-studio" + appName.contains("RubyMine", ignoreCase = true) -> "rubymine" + appName.contains("Rider", ignoreCase = true) -> "rider" + appName.contains("GoLand", ignoreCase = true) -> "goland" + appName.contains("WebStorm", ignoreCase = true) -> "webstorm" + appName.contains("IntelliJ", ignoreCase = true) || appName.contains("IDEA", ignoreCase = true) -> "intellij" + else -> null + } + + ideKey?.let { key -> + val flagKey = "$key-run-annotators" + project?.let { true } ?: false + } ?: false + } catch (e: Exception) { + // Be conservative: do NOT run annotators on failure – they can be heavy and less cancellable + false + } diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/AutocompleteUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/AutocompleteUtils.kt new file mode 100644 index 0000000..48be2f5 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/AutocompleteUtils.kt @@ -0,0 +1,55 @@ +package com.oxidecode.autocomplete + +import com.intellij.openapi.project.Project +import com.oxidecode.autocomplete.edit.AutocompleteRejectionCache +import kotlinx.coroutines.* + +class Debouncer( + private val delayMillis: () -> Long, + private val scope: CoroutineScope, + private val project: Project, + private val useAdaptiveDelay: Boolean = false, + private val action: suspend () -> Unit, +) { + private var job: Job? = null + private var lastActionTime = System.currentTimeMillis() + private val maxDebounceMs = 2000.0 // Set to 2 seconds because this is a good threshold + + fun resetTimer() { + lastActionTime = System.currentTimeMillis() + } + + fun cancel() = job?.cancel() + + private fun hasPaused(): Boolean { + val currentTime = System.currentTimeMillis() + return currentTime - lastActionTime >= getDelayMillis() + } + + private fun getDelayMillis(): Long { + val baseDelay = delayMillis() + + if (!useAdaptiveDelay) { + // Return fixed delay without adaptive behavior + return baseDelay + } + + // Adaptive delay: increases exponentially as rejections enter + val numRejections = AutocompleteRejectionCache.getInstance(project = project).getNumRejectionsInLastTimespan(timespanMs = 10_000L) + val exponentialFactor = 1.6 // Adjust this factor as needed + val adjustedDelay = baseDelay * (1 + exponentialFactor * numRejections) + return adjustedDelay.coerceIn(100.0, maxDebounceMs).toLong() + } + + fun schedule() { + job?.cancel() + val currentDelayMillis = getDelayMillis() + job = + scope.launch { + delay(currentDelayMillis) + if (hasPaused() && isActive) { + action() + } + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteImportDetector.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteImportDetector.kt new file mode 100644 index 0000000..85d0d67 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteImportDetector.kt @@ -0,0 +1,810 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.daemon.impl.DaemonCodeAnalyzerEx +import com.intellij.codeInsight.daemon.impl.HighlightInfo +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.impl.ShowIntentionActionsHandler +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile +import com.intellij.psi.SyntaxTraverser +import com.intellij.util.concurrency.AppExecutorUtil +import com.oxidecode.services.OxideCodeProjectService +import java.util.Locale.getDefault +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * Detects and logs import fixes after autocomplete code insertion. + * Uses daemon listener to piggyback on IntelliJ's existing analysis - no wasteful duplicate analysis! + */ +@Service(Service.Level.PROJECT) +class AutocompleteImportDetector( + private val project: Project, +) : Disposable { + private val logger = Logger.getInstance(AutocompleteImportDetector::class.java) + + private val pendingChecks = ConcurrentHashMap() + + // ExecutorService for running import detection tasks + private val executorService: ExecutorService = Executors.newCachedThreadPool() + + // Track running tasks by location to enable cancellation of competing checks + // Maps "filePath:offset" -> list of (taskId, future) pairs + private val runningTasksByLocation = ConcurrentHashMap>>>() + + companion object { + private const val MAX_PENDING_CHECKS = 5 + private const val STALE_TIMEOUT_MS = 15_000L // 15 seconds + private const val MIN_RETRY_ATTEMPTS = 10 + private const val MAX_RETRY_ATTEMPTS = 20 + private const val RETRY_SCALE_LINE_COUNT = 5000 // Line count at which max retries is reached + private const val RETRY_DELAY_MS = 300L + + /** + * Calculates the number of retry attempts based on document line count. + * Scales linearly from MIN_RETRY_ATTEMPTS (10) for small files to + * MAX_RETRY_ATTEMPTS (20) for files with 5000+ lines. + */ + private fun calculateRetryAttempts(lineCount: Int): Int { + val scaled = MIN_RETRY_ATTEMPTS + (lineCount * (MAX_RETRY_ATTEMPTS - MIN_RETRY_ATTEMPTS) / RETRY_SCALE_LINE_COUNT) + return scaled.coerceIn(MIN_RETRY_ATTEMPTS, MAX_RETRY_ATTEMPTS) + } + + fun getInstance(project: Project): AutocompleteImportDetector = project.getService(AutocompleteImportDetector::class.java) + } + + private data class PendingCheck( + val id: String, + val editor: Editor, + val document: Document, + val psiFile: PsiFile, + val startOffset: Int, + val length: Int, + val insertedText: String, + val retryCount: Int = 0, + val createdAt: Long = System.currentTimeMillis(), + ) + + data class ImportFixInfo( + val displayText: String, + val familyName: String, + val offset: Int, + val intentionAction: IntentionAction, + val highlightInfo: HighlightInfo?, + ) + + init { + // Subscribe to daemon events + project.messageBus.connect(OxideCodeProjectService.getInstance(project)).subscribe( + DaemonCodeAnalyzer.DAEMON_EVENT_TOPIC, + object : DaemonCodeAnalyzer.DaemonListener { + override fun daemonFinished(fileEditors: Collection) { + onDaemonFinished() + } + }, + ) + } + + /** + * Removes stale checks (older than 30 seconds) and enforces the maximum size limit. + * If there are more than MAX_PENDING_CHECKS, removes the oldest ones. + */ + private fun cleanupPendingChecks() { + val now = System.currentTimeMillis() + + // Remove stale checks (older than 30 seconds) + pendingChecks.entries.removeIf { (id, check) -> + val isStale = (now - check.createdAt) > STALE_TIMEOUT_MS + isStale + } + + // Enforce size limit by removing oldest checks + if (pendingChecks.size >= MAX_PENDING_CHECKS) { + val sortedByAge = pendingChecks.entries.sortedBy { it.value.createdAt } + val toRemove = sortedByAge.take(pendingChecks.size - MAX_PENDING_CHECKS + 1) + toRemove.forEach { (id, _) -> + pendingChecks.remove(id) + } + } + } + + /** + * Backtracks from the given offset to the nearest word boundary (whitespace or start of file). + * This is needed because autocomplete suggestions often appear after the user has partially typed + * an identifier. For example, if the user types "myV" and autocomplete inserts "ar = myVal", + * the insertionOffset will be after "myV", but we need to include "myV" in our analysis. + */ + private fun backtrackToWordBoundary( + document: Document, + offset: Int, + ): Int { + if (offset <= 0) return 0 + + val text = document.charsSequence + var currentOffset = offset - 1 + + // Backtrack while we see identifier characters (letters, digits, underscores) + while (currentOffset >= 0) { + val char = text[currentOffset] + if (!char.isLetterOrDigit() && char != '_') { + // Found a non-identifier character, so the word starts at the next position + return currentOffset + 1 + } + currentOffset-- + } + + // Reached the start of the document + return 0 + } + + /** + * Main entry point: call this after your autocomplete service inserts code. + * This just marks the insertion - the actual check happens when daemon finishes. + */ + fun onCodeInserted( + editor: Editor, + insertionOffset: Int, + insertedText: String, + ) { + val document = editor.document + val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document) + + if (psiFile == null) { + logger.warn("No PsiFile found for document") + return + } + + // Commit the document so PSI is up-to-date for pre-check + PsiDocumentManager.getInstance(project).commitDocument(document) + + // Backtrack to the nearest word boundary to include any partially-typed identifier + // For example, if user typed "myV" and autocomplete inserted "ar = myVal", + // we need to include "myV" in our check + val actualStartOffset = + try { + backtrackToWordBoundary(document, insertionOffset) + } catch (e: StringIndexOutOfBoundsException) { + // Document state has changed since we were called, bail out + return + } + val actualLength = (insertionOffset - actualStartOffset) + insertedText.length + + // Smart pre-check: only create pending check if there's meaningful code + // Skip if it's only whitespace, comments, or other non-reference elements + if (!containsMeaningfulCode(psiFile, actualStartOffset, actualLength)) { + return + } + + // Clean up stale checks and enforce size limit before adding new check + cleanupPendingChecks() + + // Check if any existing pending check already has the same insertedText + // If so, skip adding this check since it's redundant + if (pendingChecks.values.any { it.insertedText == insertedText }) { + return + } + + // Generate unique ID for this check + val checkId = UUID.randomUUID().toString() + + // Store the pending check - will be processed when daemon finishes + // Use the adjusted offset and length that includes any partially-typed identifier + val check = PendingCheck(checkId, editor, document, psiFile, actualStartOffset, actualLength, insertedText) + pendingChecks[checkId] = check + } + + /** + * Creates a unique location key from file path and offset. + */ + private fun getLocationKey( + psiFile: PsiFile, + offset: Int, + ): String = "${psiFile.virtualFile?.path ?: psiFile.name}:$offset" + + /** + * Called automatically when daemon finishes analysis. + */ + private fun onDaemonFinished() { + // Clean up stale checks before processing + cleanupPendingChecks() + + // Get snapshot of all pending checks + val checksToProcess = pendingChecks.values.toList() + + if (checksToProcess.isEmpty()) { + return + } + + // Process each check on background thread to avoid slow operations on EDT + checksToProcess.forEach { check -> + // Atomically claim this check by removing it from pendingChecks before processing. + // If another daemon event already claimed it (remove returns null), skip this check. + // This prevents the same check from being processed multiple times when the daemon + // fires multiple times in quick succession. + if (pendingChecks.remove(check.id) == null) { + return@forEach + } + + val taskId = check.id + val locationKey = getLocationKey(check.psiFile, check.startOffset) + val future = + executorService.submit { + try { + var foundFixes = false + val retryAttempts = calculateRetryAttempts(check.document.lineCount) + + // Retry up to retryAttempts times with RETRY_DELAY_MS delay between attempts + for (attempt in 1..retryAttempts) { + // Check if this task has been cancelled/interrupted + if (Thread.currentThread().isInterrupted) { + logger.info("Import check cancelled for location $locationKey") + return@submit + } + + foundFixes = detectAndShowImportFixes(check.id, check.editor, check.psiFile, check.startOffset, check.length) + + if (foundFixes) { + // Cancel other checks at the same location since we found fixes + cancelOtherChecksAtLocation(locationKey, taskId) + break + } + + // Wait before next attempt (unless this was the last attempt) + if (attempt < retryAttempts) { + Thread.sleep(RETRY_DELAY_MS) + } + } + + if (!foundFixes) { + logger.info( + "No import fixes found after $retryAttempts attempts (id: $taskId, insertedText: '${check.insertedText}')", + ) + } + } catch (e: InterruptedException) { + // Task was cancelled, clean up gracefully + logger.info( + "Import check interrupted for location $locationKey (id: $taskId, insertedText: '${check.insertedText}')", + ) + Thread.currentThread().interrupt() // Restore interrupt status + } finally { + // Remove this task from tracking + runningTasksByLocation[locationKey]?.removeIf { it.first == taskId } + if (runningTasksByLocation[locationKey]?.isEmpty() == true) { + runningTasksByLocation.remove(locationKey) + } + } + } + + // Track this future by location with task ID + runningTasksByLocation.computeIfAbsent(locationKey) { mutableListOf() }.add(taskId to future) + } + } + + /** + * Cancels all other running checks at the same location except the current one. + * Called when a check successfully finds import fixes. + */ + private fun cancelOtherChecksAtLocation( + locationKey: String, + currentTaskId: String, + ) { + runningTasksByLocation[locationKey]?.forEach { (taskId, future) -> + if (taskId != currentTaskId && !future.isDone) { + future.cancel(false) // Interrupt the thread + logger.debug("Cancelled competing import check at location $locationKey (task: $taskId)") + } + } + } + + /** + * Core detection logic - finds and logs all import fixes in the inserted range. + * Uses ShowIntentionsPass to query for available quick fixes at each offset in the inserted range. + * Returns true if any import fixes were found, false otherwise. + */ + private fun detectAndShowImportFixes( + checkId: String, + editor: Editor, + psiFile: PsiFile, + startOffset: Int, + length: Int, + ): Boolean { + val endOffset = startOffset + length + + // Data class to hold extracted information from read action + data class FixDescriptorData( + val action: IntentionAction, + val highlightInfo: HighlightInfo, + val referenceName: String, + val offset: Int, + ) + + // Phase 1: Read action for minimal data extraction (only PSI-dependent operations) + val fixDescriptors = + ReadAction.compute, RuntimeException> { + // Sample key offsets in the inserted range to check for import fixes + // We'll check at the start, middle, and end to catch any unresolved references + val offsetsToCheck = mutableSetOf() + offsetsToCheck.add(startOffset) + if (length > 1) { + offsetsToCheck.add(startOffset + length / 2) + offsetsToCheck.add(endOffset - 1) + } + + // Also check every word boundary in the inserted text to catch all potential unresolved references + val document = editor.document + val insertedText = + if (endOffset <= document.textLength) { + document.charsSequence.subSequence(startOffset, endOffset).toString() + } else { + "" + } + + // Add offsets for word boundaries only (start of each identifier) + var currentOffset = startOffset + var prevWasIdentifierChar = false + for (char in insertedText) { + val isIdentifierChar = char.isLetterOrDigit() || char == '_' + + // Add offset only at the start of a word (transition from non-identifier to identifier) + if (isIdentifierChar && !prevWasIdentifierChar) { + offsetsToCheck.add(currentOffset) + } + + prevWasIdentifierChar = isIdentifierChar + currentOffset++ + } + + // Extract data that requires read action (minimal scope) + val extractedData = mutableListOf() + + for (offset in offsetsToCheck) { + // Collect HighlightInfo objects near this offset + val highlightInfos = mutableListOf() + + // CRITICAL: processHighlights requires read action (verified by platform assertion) + DaemonCodeAnalyzerEx.processHighlights( + document, + project, + HighlightSeverity.INFORMATION, + offset, + offset, + ) { info -> + highlightInfos.add(info) + true // Continue processing + } + + // Process each HighlightInfo to extract intention action descriptors + for (highlightInfo in highlightInfos) { + // Check if project is disposed before accessing IDE APIs + if (project.isDisposed) { + logger.debug("Project disposed, stopping import detection") + return@compute emptyList() + } + + // Extract all quick fixes (both immediate and lazy) from the HighlightInfo + // using findRegisteredQuickFix which internally accesses both intentionActionDescriptors and lazyQuickFixes + val fixes = mutableListOf() + highlightInfo.findRegisteredQuickFix { descriptor, _ -> + fixes.add(descriptor) + null // Return null to continue iterating through all fixes + } + + val referenceName = highlightInfo.text + + // Store the extracted data for processing outside read action + for (descriptor in fixes) { + extractedData.add( + FixDescriptorData( + action = descriptor.action, + highlightInfo = highlightInfo, + referenceName = referenceName, + offset = offset, + ), + ) + } + } + } + + extractedData + } + + // Phase 2: Process fix descriptors outside read action (no PSI access needed) + val importFixesByOffset = mutableMapOf>() + + // Get IDE name once for use in import fix detection + val ideName = + try { + ApplicationInfo.getInstance().fullApplicationName + } catch (e: Exception) { + "" + } + + for (descriptorData in fixDescriptors) { + val action = descriptorData.action + + // Access familyName with a cancellable/nonblocking read action to avoid IllegalStateException + // This is required for TypeScript language service fixes in WebStorm which need a cancellable context + // Retry up to 5 times with a small delay to ensure we get the familyName + var familyName: String? = null + var lastException: Exception? = null + + for (attempt in 1..5) { + try { + familyName = + ReadAction + .nonBlocking { + action.familyName + }.submit(AppExecutorUtil.getAppExecutorService()) + .get(100, TimeUnit.MILLISECONDS) + break // Success, exit retry loop + } catch (e: ProcessCanceledException) { + // ProcessCanceledException must be rethrown (control flow exception) + throw e + } catch (e: TimeoutException) { + // Treat timeout like other retriable exceptions + lastException = e + if (attempt < 5) { + Thread.sleep(5) + } + } catch (e: Exception) { + lastException = e + if (attempt < 5) { + // Wait 5ms before retrying + Thread.sleep(5) + } + } + } + + if (familyName == null) { + // All retries failed, skip this fix + logger.debug("Failed to read action.familyName after 5 attempts, skipping fix", lastException) + continue + } + + // Check if this is an import-related fix first + if (isImportFix(familyName, ideName)) { + // Try to access action.text with a cancelable/nonblocking read action + // If it fails or times out, fall back to the reference name logic + val actionText = + try { + ReadAction + .nonBlocking { + action.text + }.submit(AppExecutorUtil.getAppExecutorService()) + .get(100, TimeUnit.MILLISECONDS) + } catch (e: ProcessCanceledException) { + // ProcessCanceledException must be rethrown (control flow exception) + throw e + } catch (e: Exception) { + // If read action fails for other reasons, use null to trigger fallback + logger.debug("Failed to read action.text, using fallback display text", e) + null + } + + // Use custom display text for PyCharm/PhpStorm or if actionText failed, otherwise use the action text + val displayText = + if (isIDEThatNeedsSpecialName(ideName) || actionText == null) { + "import ${descriptorData.referenceName}" + } else { + actionText + } + + val fixInfo = + ImportFixInfo( + displayText = displayText, + familyName = familyName, + offset = descriptorData.offset, + intentionAction = action, + highlightInfo = descriptorData.highlightInfo, + ) + importFixesByOffset.getOrPut(descriptorData.offset) { mutableListOf() }.add(fixInfo) + } + } + + // Get unique fixes + val uniqueFixes = importFixesByOffset.values.flatten().distinctBy { it.displayText } + + // UI display outside read action (must NOT be in read action per IntelliJ guidelines) + if (uniqueFixes.isEmpty()) { + return false + } else { + ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed && !editor.isDisposed) { + queueAndTryToShowImportFixSuggestion(checkId, editor, psiFile, uniqueFixes) + } + } + + return true + } + } + + private fun isImportFix( + familyName: String, + ideName: String, + ): Boolean { + // On Linux, use a simple check for the feature flag value in family name + if (System.getProperty("os.name").lowercase().contains("linux")) { + val checkString = "import" + logger.info("isImportFix check - familyName: $familyName, ideName: $ideName, checkString: $checkString") + return familyName.lowercase().contains(checkString.lowercase()) && + !familyName.lowercase().contains("optimize") + } + + // Check family name for import-related keywords based on IDE type + return when { + ideName.contains("PyCharm", ignoreCase = true) -> familyName == "Import" + ideName.contains("IntelliJ", ignoreCase = true) -> familyName == "Import" + ideName.contains("RustRover", ignoreCase = true) -> familyName == "Import" + ideName.contains("Android Studio", ignoreCase = true) -> familyName == "Import" + ideName.contains("WebStorm", ignoreCase = true) -> familyName == "Missing import statement" + ideName.contains("PhpStorm", ignoreCase = true) -> familyName == "Import class" + else -> familyName == "Import" + } + } + + /** + * Checks if the current IDE is PyCharm, PhpStorm, RustRover, or IntelliJ + */ + private fun isIDEThatNeedsSpecialName(ideName: String): Boolean = + ideName.contains("PyCharm", ignoreCase = true) || + ideName.contains("PhpStorm", ignoreCase = true) || + ideName.contains("RustRover", ignoreCase = true) || + ideName.contains("IntelliJ", ignoreCase = true) + + /** + * Creates and shows an import fix suggestion as a PopupSuggestion + * integrated with the autocomplete system. + * + * Multiple import fixes from the same autocomplete insertion are combined into a single + * popup suggestion to prevent the issue where accepting one import fix (which may trigger + * IntelliJ's import chooser popup) cancels the next queued import fix. + * + * It is possible that the import fix suggestion will not be valid anymore at this point in time + */ + private fun queueAndTryToShowImportFixSuggestion( + checkId: String, + editor: Editor, + psiFile: PsiFile, + importFixes: List, + ) { + if (importFixes.isEmpty()) { + return + } + + val document = editor.document + + // Validate that the highlightInfo ranges still match the expected reference names + // This ensures the document hasn't changed since we detected the import fix + val validImportFixes = + importFixes.filter { fix -> + val highlightInfo = fix.highlightInfo ?: return@filter false + val expectedText = highlightInfo.text + val startOffset = highlightInfo.startOffset + val endOffset = highlightInfo.endOffset + + // Check bounds + if (startOffset < 0 || endOffset > document.textLength || startOffset >= endOffset) { + return@filter false + } + + // Get the actual text at the highlight range + val actualText = document.charsSequence.subSequence(startOffset, endOffset).toString() + + // Only include this fix if the text still matches exactly + if (actualText != expectedText) { + return@filter false + } + true + } + + if (validImportFixes.isEmpty()) { + // No valid fixes remain, clean up the pending check + pendingChecks.remove(checkId) + return + } + + // Get unique import fixes by display text to avoid duplicates + val uniqueImportFixes = validImportFixes.distinctBy { it.displayText } + + val tracker = RecentEditsTracker.getInstance(project) + + // Use the first valid import fix for positioning the popup + val firstFix = uniqueImportFixes.first() + val firstHighlightInfo = firstFix.highlightInfo + val expectedText = firstHighlightInfo?.text + if (expectedText.isNullOrEmpty() || firstHighlightInfo == null) { + pendingChecks.remove(checkId) + return + } + + // Position popup at the first unresolved reference location + val referenceEndOffset = firstHighlightInfo.endOffset + + // Combine all import display texts into a single content string + val combinedDisplayText = uniqueImportFixes.joinToString("\n") { it.displayText } + + // Create a single combined PopupSuggestion for all import fixes + val suggestion = + AutocompleteSuggestion + .PopupSuggestion( + content = combinedDisplayText, + startOffset = referenceEndOffset, + endOffset = referenceEndOffset, + oldContent = "", + fileExtension = psiFile.virtualFile?.extension ?: "txt", + project = project, + autocomplete_id = "import-fix-${UUID.randomUUID()}", + editor = editor, + onAcceptOverride = { ed -> + // When accepted (Tab pressed), apply all import fixes in sequence + applyMultipleImportFixes(ed, psiFile, uniqueImportFixes) + }, + // Store the first intention action for validation purposes in RecentEditsTracker + importFixIntentionAction = firstFix.intentionAction, + ).apply { + onDispose = { + // Clear the specific pending check when suggestion is disposed + pendingChecks.remove(checkId) + } + } + + // Queue this combined suggestion + tracker.queueAndTryToShowImportFixSuggestion( + suggestion = suggestion, + expectedText = expectedText, + highlightStartOffset = firstHighlightInfo.startOffset, + highlightEndOffset = firstHighlightInfo.endOffset, + ) + } + + /** + * Applies multiple import fixes by invoking each intention action in sequence. + * This is used when multiple imports are needed from a single autocomplete insertion. + */ + private fun applyMultipleImportFixes( + editor: Editor, + psiFile: PsiFile, + importFixes: List, + ) { + if (importFixes.isEmpty()) return + + // Apply the first import fix, then schedule the rest + val firstFix = importFixes.first() + val remainingFixes = importFixes.drop(1) + + applyImportFix(editor, psiFile, firstFix) + + // If there are more fixes, apply them after a short delay to allow the first one to complete + if (remainingFixes.isNotEmpty()) { + ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed && !editor.isDisposed) { + // Recursively apply remaining fixes + applyMultipleImportFixes(editor, psiFile, remainingFixes) + } + } + } + } + + /** + * Applies an import fix by invoking the intention action + */ + private fun applyImportFix( + editor: Editor, + psiFile: PsiFile, + importFix: ImportFixInfo, + ) { + try { + // Commit the document before modifying PSI + PsiDocumentManager.getInstance(project).commitDocument(editor.document) + + // Invoke using the same semantics as IntelliJ's ShowIntentionActionsHandler + val action = importFix.intentionAction + val app = ApplicationManager.getApplication() + val runChoose = { + ShowIntentionActionsHandler.chooseActionAndInvoke( + psiFile, + editor, + action, + action.text, + importFix.offset, + ) + } + + if (action.startInWriteAction()) { + // Platform will wrap invoke() in a write action itself; if we're already under write, call directly. + if (app.isWriteAccessAllowed) { + runChoose() + } else { + // Ensure EDT + app.invokeLater { + if (!project.isDisposed && !editor.isDisposed) runChoose() + } + } + } else { + // Must not run under a write action when the action shows UI/popups. + app.invokeLater { + if (!project.isDisposed && !editor.isDisposed) runChoose() + } + } + } catch (e: Exception) { + logger.warn("Failed to apply import fix", e) + } + } + + /** + * Checks if the inserted range contains any unresolved references that might need imports. + * Returns false if there are no unresolved references (e.g., only whitespace, comments, or resolved code). + */ + private fun containsMeaningfulCode( + psiFile: PsiFile, + startOffset: Int, + length: Int, + ): Boolean { + val endOffset = startOffset + length + + // Only check leaf elements (actual tokens/identifiers) - container elements never have references + // Filter out whitespace and comments which are also leaf elements but don't need imports + val hasUnresolvedReferences = + SyntaxTraverser + .psiTraverser(psiFile) + .onRange( + com.intellij.openapi.util + .TextRange(startOffset, endOffset), + ).traverse() + .any { element -> + val elementType = + element.node + ?.elementType + ?.toString() + ?.lowercase(getDefault()) ?: return@any false + elementType.contains("identifier") || + elementType.contains("reference") || + elementType.contains("directive") + } + + return hasUnresolvedReferences + } + + override fun dispose() { + pendingChecks.clear() + runningTasksByLocation.clear() + + // Immediately shutdown the executor to stop accepting new tasks + executorService.shutdown() + + // Move the blocking termination wait to a background thread to avoid delaying IDE shutdown + ApplicationManager.getApplication().executeOnPooledThread { + try { + if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { + executorService.shutdownNow() + // Give a final chance for tasks to respond to interruption + if (!executorService.awaitTermination(1, TimeUnit.SECONDS)) { + logger.warn("AutocompleteImportDetector executor did not terminate gracefully after shutdownNow") + } + } + } catch (e: InterruptedException) { + executorService.shutdownNow() + Thread.currentThread().interrupt() + logger.warn("Interrupted while waiting for AutocompleteImportDetector executor termination") + } + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteRejectionCache.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteRejectionCache.kt new file mode 100644 index 0000000..4eb9ca3 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteRejectionCache.kt @@ -0,0 +1,133 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.oxidecode.utils.EvictingQueue + +@Service(Service.Level.PROJECT) +class AutocompleteRejectionCache( + private val project: Project, +) : Disposable { + companion object { + fun getInstance(project: Project): AutocompleteRejectionCache = project.getService(AutocompleteRejectionCache::class.java) + + private const val REJECTION_THRESHOLD_FLAG = "autocomplete-rejection-cache-threshold-ms" + } + + private val shownCache = EvictingQueue>(maxSize = 20) // Cache for shown suggestions + + private val rejectionCache = EvictingQueue>(maxSize = 20) // Cache for rejected suggestions. The first item is the suggestion string, and the second is the timestamp + + // Cache for accepted suggestions. This is used so accepted suggestions don't get added to rejection cache + private val acceptanceCache = EvictingQueue>(maxSize = 20) + + fun checkIfSuggestionShouldBeShown(suggestion: AutocompleteSuggestion): Boolean { + // First check if any entries have timed out + val currentTime = System.currentTimeMillis() + val autoCompleteRejectionCacheThresholdMs = 30_000L + // If the suggestion was rejected + // previous rejection is a >80% substring of the current suggestion this handles deletions + val suggestionInPreviousRejections = + rejectionCache.any { + currentTime - it.second < autoCompleteRejectionCacheThresholdMs && + ( + it.first == suggestion.rejectionCacheKey() || + ( + suggestion.rejectionCacheKey().contains(it.first) && + it.first.length.toDouble() / suggestion.rejectionCacheKey().length.toDouble() > 0.8 + ) + ) + } + if (suggestionInPreviousRejections) { + return false + } + // also don't show if we've shown this suggestion 2x already, and it's not a ghost text + val maxShowCount = 2 + return shownCache.count { it.first == suggestion.rejectionCacheKey() } < maxShowCount + } + + fun tryAddingRejectionToCache( + suggestion: AutocompleteSuggestion, + reason: AutocompleteDisposeReason, + ) { + // ACCEPTED - do not add to rejection cache + // IMPORT_FIX_SHOWN - do not add to rejection cache (import suggestions are context-specific) + // AUTOCOMPLETE_DISPOSED - esc also maps to this + // CLEARING_PREVIOUS_AUTOCOMPLETE - soft rejection, usually means that user typed + // ESCAPE_PRESSED - always add to rejection cache + // EDITOR_LOST_FOCUS - soft rejection + // CARET_POSITION_CHANGED - soft rejection + if (reason in + listOf( + AutocompleteDisposeReason.ACCEPTED, + AutocompleteDisposeReason.IMPORT_FIX_SHOWN, + ) + ) { + acceptanceCache.add(Pair(suggestion.rejectionCacheKey(), System.currentTimeMillis())) + return + } + var shouldAddRejectionToCache = false + if (suggestion.rejectionCacheKey() in acceptanceCache.map { it.first }) { + return + } + + // Add if the suggestion was shown, and it's not already in the map, and it's not a ghost text + // And it's been shown for more than 300ms + if (suggestion.getLifespan() > 300L) { + shownCache.add(Pair(suggestion.rejectionCacheKey(), System.currentTimeMillis())) + } + + if (reason in + listOf( + // Soft rejections + AutocompleteDisposeReason.CARET_POSITION_CHANGED, + AutocompleteDisposeReason.CLEARING_PREVIOUS_AUTOCOMPLETE, + ) + ) { + if (suggestion.type == AutocompleteSuggestion.SuggestionType.JUMP_TO_EDIT) { + shouldAddRejectionToCache = suggestion.getLifespan() > 500L + } else { + shouldAddRejectionToCache = suggestion.getLifespan() > 750L + } + } else if (reason in + listOf( + // Hard rejections + AutocompleteDisposeReason.EDITOR_LOST_FOCUS, + AutocompleteDisposeReason.AUTOCOMPLETE_DISPOSED, + ) + ) { + if (suggestion.type == AutocompleteSuggestion.SuggestionType.JUMP_TO_EDIT) { + shouldAddRejectionToCache = suggestion.getLifespan() > 500L + } else if (suggestion.type == AutocompleteSuggestion.SuggestionType.POPUP) { + shouldAddRejectionToCache = suggestion.getLifespan() > 500L + } else if (suggestion.type == AutocompleteSuggestion.SuggestionType.GHOST_TEXT) { + shouldAddRejectionToCache = suggestion.getLifespan() > 1000L + } + } else if (reason == AutocompleteDisposeReason.ESCAPE_PRESSED) { + shouldAddRejectionToCache = suggestion.getLifespan() > 1500L + } + // Add if the suggestion was shown and it's not already in the map + if (shouldAddRejectionToCache && suggestion.rejectionCacheKey() !in rejectionCache.map { it.first }) { + rejectionCache.add(Pair(suggestion.rejectionCacheKey(), System.currentTimeMillis())) + } + } + + fun getNumRejectionsInLastTimespan(timespanMs: Long): Int { + val currentTime = System.currentTimeMillis() + return rejectionCache.count { currentTime - it.second <= timespanMs } + } + + /** + * Clears the rejection cache + */ + fun clearCache() { + rejectionCache.clear() + shownCache.clear() + acceptanceCache.clear() + } + + override fun dispose() { + clearCache() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteSuggestion.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteSuggestion.kt new file mode 100644 index 0000000..94690b5 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/AutocompleteSuggestion.kt @@ -0,0 +1,1070 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.* +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.oxidecode.services.OxideCodeProjectService +import com.oxidecode.theme.OxideCodeColors +import com.oxidecode.theme.withAlpha +import com.oxidecode.utils.computeDiffGroups +import com.oxidecode.utils.isAllAdditions +import com.oxidecode.views.PopupEditorComponent +import java.awt.Component +import java.awt.Font +import kotlin.math.abs + +/** + * Represents an autocomplete suggestion that can be displayed + * either as a popup panel or as ghost text at the cursor position + */ +sealed class AutocompleteSuggestion : Disposable { + abstract val content: String + abstract val startOffset: Int + abstract val endOffset: Int + abstract var suggestionAdditions: Int + abstract var suggestionDeletions: Int + var shownTime: Long = 0 + var disposedTime: Long = 0 + abstract val autocomplete_id: String + var onDispose: () -> Unit = {} + + // Retrieval metrics - how many usages/definitions were retrieved for this autocomplete request + var numDefinitionsRetrieved: Int = 0 + var numUsagesRetrieved: Int = 0 + + val isImportFix: Boolean + get() = autocomplete_id.startsWith("import-fix-") + + /** + * Calculates the offset adjustment after this suggestion is applied. + * + * WARNING: This method CANNOT be used for import fixes! + * Import fixes trigger an intention action that adds import statements at the TOP of the file, + * which happens OUTSIDE this suggestion's range. This method only measures the difference + * between the suggestion content and the range it replaces, so it would miss the import change entirely. + * + * For import fixes, use document.textLength difference before/after the import intention action instead. + */ + open fun getAdjustmentOffset(): Int { + if (isImportFix) { + logger.error( + "getAdjustmentOffset() called on an import fix suggestion (id=$autocomplete_id). " + + "This is incorrect! Import fixes add text at the TOP of the file (outside this suggestion's range), " + + "so this method will return an incorrect value. " + + "Use document.textLength difference before/after the import intention action instead.", + ) + } + return content.length - (endOffset - startOffset) + } + + abstract fun show( + editor: Editor, + isPostJumpSuggestion: Boolean = false, + ) + + open fun update(editor: Editor): Int? = null + + abstract fun accept(editor: Editor): Disposable? + + abstract override fun dispose() + + fun getLifespan(): Long = disposedTime - shownTime + + fun suggestionWasShownAtAll(): Boolean = shownTime > 0 + + // Cache key for rejection logic, use "jump_to_edit_offset:" for JumpToEditSuggestion, content otherwise + open fun rejectionCacheKey(): String = content + + enum class SuggestionType { + POPUP, + GHOST_TEXT, + JUMP_TO_EDIT, + MULTIPLE_GHOST_TEXT, + } + + /** + * Returns the type of suggestion based on the class + */ + val type: SuggestionType + get() = + when (this) { + is PopupSuggestion -> SuggestionType.POPUP + is GhostTextSuggestion -> SuggestionType.GHOST_TEXT + is JumpToEditSuggestion -> SuggestionType.JUMP_TO_EDIT + is MultipleGhostTextSuggestion -> SuggestionType.MULTIPLE_GHOST_TEXT + } + + /** + * Suggestion displayed as a popup editor component + */ + data class PopupSuggestion( + override var content: String, + override var startOffset: Int, + override val endOffset: Int, + override var suggestionAdditions: Int = 0, + override var suggestionDeletions: Int = 0, + override val autocomplete_id: String, + val oldContent: String, + val fileExtension: String, + val project: Project, + val editor: Editor, + val onAcceptOverride: ((Editor) -> Unit)? = null, + val importFixIntentionAction: com.intellij.codeInsight.intention.IntentionAction? = null, + ) : AutocompleteSuggestion() { + private var popupEditor: PopupEditorComponent? = null + private var adjustmentOffset: Int = 0 + var initialCursorLine: Int = -1 + + override fun getAdjustmentOffset(): Int = adjustmentOffset + + override fun show( + editor: Editor, + isPostJumpSuggestion: Boolean, + ) { + // Track the initial cursor line when the popup is first shown + initialCursorLine = editor.caretModel.logicalPosition.line + + popupEditor = + PopupEditorComponent( + project = project, + oldContent = oldContent, + content = content, + fileExtension = fileExtension, + globalEditor = editor, + startOffset = startOffset, + isPostJumpSuggestion = isPostJumpSuggestion, + isImportFix = isImportFix, + ) { onDispose() } + adjustmentOffset = popupEditor?.adjustmentOffset ?: 0 + popupEditor?.showNearCaret(editor) + suggestionAdditions = popupEditor?.charsAdded ?: 0 + suggestionDeletions = popupEditor?.charsDeleted ?: 0 + } + + override fun accept(editor: Editor): Disposable? { + // If there's a custom accept handler (e.g., for import fixes), use it + onAcceptOverride?.let { + it(editor) + return null + } + + // Otherwise, use the default popup accept behavior + return popupEditor?.accept(editor) ?: run { + val document = editor.document + val docLen = document.textLength + val safeStart = startOffset.coerceIn(0, docLen) + val safeEnd = endOffset.coerceIn(safeStart, docLen) + document.replaceString(safeStart, safeEnd, content) + null + } + } + + override fun dispose() { + popupEditor?.dispose() + popupEditor = null + editor.component.repaint() + } + } + + /** + * Suggestion displayed as ghost text at the cursor position + */ + data class GhostTextSuggestion( + override var content: String, + override var startOffset: Int, + override val autocomplete_id: String, + private val document: Document, + val editor: Editor, + var forceHighlight: Boolean = false, + ) : AutocompleteSuggestion() { + override var endOffset: Int = startOffset + private var initialDocumentLength: Int = document.text.length + private var shouldShowMultiline = content.contains("\n") + private var endedWithNewLine = content.endsWith("\n") + var initialCursorLine: Int = -1 + + private var startOffsetToRender: Int = startOffset + private var contentToRender = content + val isAtCaret + get() = startOffsetToRender == ReadAction.compute { editor.caretModel.offset } + + init { + updateContentToRender() + } + + fun updateContentToRender() { + val charsSequence = document.charsSequence + val startsOnNewline = charsSequence.getOrNull(startOffset - 1) == '\n' + val char = charsSequence.getOrNull(startOffset) + val isDoubleNewline = char == '\n' || char == null + contentToRender = content + startOffsetToRender = startOffset + endedWithNewLine = content.endsWith("\n") + if (!content.startsWith("\n") && content.endsWith("\n")) { + if (startsOnNewline && !isDoubleNewline) { + contentToRender = content.dropLast(1) + contentToRender = "\n" + contentToRender + startOffsetToRender = startOffset - 1 + } + } + } + + override var suggestionAdditions: Int = content.length + override var suggestionDeletions: Int = 0 + + private var inlineInlay: Inlay? = null + private var blockInlay: Inlay? = null + private var trailingInlineInlay: Inlay? = null + private val renderers = mutableListOf() + private var hasUpdatedContent = false + + /** + * Checks if the caret is one position away from the start of this ghost text suggestion + * and there's a newline character at the caret position + */ + fun isNewlineOnNextLine( + caretOffset: Int, + document: Document, + ): Boolean { + val charsSequence = document.charsSequence + return caretOffset == startOffset - 1 && + caretOffset < charsSequence.length && + ReadAction.compute { charsSequence[caretOffset] } == '\n' + } + + override fun show( + editor: Editor, + isPostJumpSuggestion: Boolean, + ) { + // Track the initial cursor position when the ghost text is first shown + if (initialCursorLine == -1) { + initialCursorLine = editor.caretModel.logicalPosition.line + } + + // cannot do Disposer.dispose(this) here as we dont acutally want to "dispose" this entire thing + dispose() + + val lines = contentToRender.lines() + var firstLineContent = lines.firstOrNull() ?: "" + var remainderContent = if (lines.size > 1) lines.drop(1).joinToString("\n") else "" + val isPureWhitespace = contentToRender.isBlank() + + var trailingInlineCode = "" + + val endsWithNewLine = contentToRender.endsWith("\n") + val startsOnNewline = document.text.getOrNull(startOffset - 1) == '\n' + if (!endsWithNewLine && lines.size > 1 && startsOnNewline && !endedWithNewLine) { + firstLineContent = "" + remainderContent = lines.dropLast(1).joinToString("\n") + trailingInlineCode = lines.last() + startOffsetToRender -= 1 + } + + val attributes = + TextAttributes().apply { + foregroundColor = editor.colorsScheme.defaultForeground.withAlpha(0.75f) + backgroundColor = + if (forceHighlight || isPureWhitespace) OxideCodeColors.whitespaceHighlightColor else OxideCodeColors.transparent + effectType = null + fontType = Font.PLAIN + } + + val properties = + InlayProperties().apply { + relatesToPrecedingText(true) + disableSoftWrapping(true) + } + + if (firstLineContent.isNotEmpty()) { + val inlineRenderer = + GhostTextRenderer( + editor = editor, + text = firstLineContent, + attributes = attributes, + showHint = true, + project = editor.project, + fileExtension = editor.virtualFile?.extension, + offset = startOffset, + ) + + // Register renderer as disposable child using OxideCodeProjectService as parent + val parentDisposable = + editor.project?.let { + OxideCodeProjectService.getInstance(it) + } ?: Disposer.newDisposable() + Disposer.register(parentDisposable, inlineRenderer) + renderers.add(inlineRenderer) + + inlineInlay = + editor.inlayModel.addInlineElement( + startOffsetToRender, + properties, + inlineRenderer, + ) as Inlay + } + + if (shouldShowMultiline) { + val blockRenderer = + GhostTextRenderer( + editor = editor, + text = remainderContent, + attributes = attributes, + showHint = false, + project = editor.project, + fileExtension = editor.virtualFile?.extension, + offset = startOffset, + followsNewline = firstLineContent.isEmpty(), + ) + + // Register renderer as disposable child using OxideCodeProjectService as parent + val parentDisposable = + editor.project?.let { + OxideCodeProjectService.getInstance(it) + } ?: Disposer.newDisposable() + Disposer.register(parentDisposable, blockRenderer) + renderers.add(blockRenderer) + + blockInlay = + editor.inlayModel.addBlockElement( + startOffsetToRender, + properties, + blockRenderer, + ) as Inlay + } + + if (trailingInlineCode.isNotEmpty()) { + val inlineRenderer = + GhostTextRenderer( + editor = editor, + text = trailingInlineCode, + attributes = attributes, + showHint = false, + project = editor.project, + fileExtension = editor.virtualFile?.extension, + offset = startOffset, + ) + + // Register renderer as disposable child using OxideCodeProjectService as parent + val parentDisposable = + editor.project?.let { + OxideCodeProjectService.getInstance(it) + } ?: Disposer.newDisposable() + Disposer.register(parentDisposable, inlineRenderer) + renderers.add(inlineRenderer) + + trailingInlineInlay = + editor.inlayModel.addInlineElement( + startOffsetToRender + 1, + properties, + inlineRenderer, + ) as Inlay + } + } + + override fun update(editor: Editor): Int? { + val document = editor.document + val docLen = document.textLength + if (docLen < initialDocumentLength) return null + + val cursorOffset = ApplicationManager.getApplication().runReadAction { editor.caretModel.offset } + + // handles pressing enter when change is on next line + // a bit buggy still but will fully fix later + val isNewlineOnNextLine = isNewlineOnNextLine(cursorOffset, document) + + // Validate caret alignment early to avoid computing invalid ranges + if (!isNewlineOnNextLine && startOffset != cursorOffset) return null + + val startOffsetToUseRaw = if (isNewlineOnNextLine) cursorOffset else startOffset + + // Clamp to document bounds + val safeStart = startOffsetToUseRaw.coerceIn(0, docLen) + val delta = (docLen - initialDocumentLength).coerceAtLeast(0) + val safeEnd = (safeStart + delta).coerceIn(safeStart, docLen) + + if (safeEnd <= safeStart) return null + + var userTypedText = + ApplicationManager.getApplication().runReadAction { + document.charsSequence.subSequence(safeStart, safeEnd).toString() + } + + if (isNewlineOnNextLine) { + if (!userTypedText.startsWith("\n")) return null + userTypedText = userTypedText.removePrefix("\n") + } + + // Check prefix case (existing logic) + if (content.startsWith(userTypedText)) { + val remainingText = content.substring(userTypedText.length) + if (remainingText.isBlank()) return null + + content = remainingText + startOffset = safeEnd + endOffset = safeEnd + updateContentToRender() + suggestionAdditions = content.length + initialDocumentLength = docLen + hasUpdatedContent = true + + // For prefix updates, try to update the inline inlay in place + inlineInlay?.takeIf { !shouldShowMultiline }?.let { + // Update the renderer by trimming the prefix + val renderer = it.renderer as? GhostTextRenderer + renderer?.updateByTrimmingPrefix(userTypedText.length) + + // Update the inlay to trigger a repaint + it.update() + } ?: run { + // Fallback to recreating if it's multiline or no inline inlay exists + dispose() + show(editor) + } + + return userTypedText.length + } + + // Check suffix case for closing brackets - always recreate for suffix + val setOfClosingBrackets = setOf('}', ']', ')', '"', '\'', '>') + if (userTypedText.isNotEmpty() && + content.endsWith(userTypedText) && + userTypedText.last() in setOfClosingBrackets && + hasUpdatedContent + ) { + val remainingText = content.substring(0, content.length - userTypedText.length) + if (remainingText.isEmpty()) return null + + content = remainingText + // Keep the same startOffset since we're removing from the end + endOffset = startOffset + updateContentToRender() + suggestionAdditions = content.length + initialDocumentLength = docLen + + // For suffix, always recreate (status quo) + dispose() + show(editor) + + return userTypedText.length + } + + return null + } + + private fun fixSoftWrap() { + if (editor.settings.isUseSoftWraps) { + ApplicationManager.getApplication().invokeLater { + // Trigger a 0-pixel resize event to force layout recalculation + fun incrementWidth(component: Component) { + component.setSize(component.width + 1, component.height) + component.setSize(component.width - 1, component.height) + component.revalidate() + component.repaint() + } + editor.contentComponent.parent?.let { incrementWidth(it) } + } + } + } + + override fun accept(editor: Editor): Disposable? { + val document = editor.document + val docLen = document.textLength + val safeStart = startOffset.coerceIn(0, docLen) + val safeEnd = endOffset.coerceIn(safeStart, docLen) + if (safeStart == safeEnd && safeStart == docLen) { + content = content.removeSuffix("\n") + } + val contentBefore = content + // idfk why but this is needed to ensure the content is not modified during the replaceString operation + // jetbrains internally alters `content` for some reason + document.replaceString(safeStart, safeEnd, content) + content = contentBefore + + val distance = if (content.isBlank()) content.length else content.trimEnd().length + val newCaret = (safeStart + distance).coerceIn(0, editor.document.textLength) + editor.caretModel.moveToOffset(newCaret) + editor.selectionModel.setSelection(newCaret, newCaret) + + fixSoftWrap() + + return null + } + + override fun dispose() { + // Dispose all renderers first to ensure proper cleanup + renderers.forEach { renderer -> + try { + Disposer.dispose(renderer) + } catch (e: Exception) { + logger.warn("Error disposing GhostTextRenderer: $e") + } + } + renderers.clear() + + // Then dispose inlays + inlineInlay?.let { + Disposer.dispose(it) + } + inlineInlay = null + blockInlay?.let { + Disposer.dispose(it) + } + blockInlay = null + trailingInlineInlay?.let { + Disposer.dispose(it) + } + trailingInlineInlay = null + fixSoftWrap() + editor.component.repaint() + } + } + + /** + * Suggestion that directs the user to jump to a distant edit location + */ + data class JumpToEditSuggestion( + override val content: String, + override val startOffset: Int, + override val endOffset: Int, + override var suggestionAdditions: Int = 0, + override var suggestionDeletions: Int = 0, + val originalCompletion: NextEditAutocompletion, + override val autocomplete_id: String, + val oldContent: String, + val project: Project, + val editor: Editor, + ) : AutocompleteSuggestion() { + private val adjustedStartOffset: Int = + startOffset + oldContent.commonPrefixWith(content).length + private var jumpHintManager: JumpHintManager? = null + private val document = editor.document + private val lineNumber = document.getLineNumber(maxOf(0, adjustedStartOffset - 1)) + + override fun show( + editor: Editor, + isPostJumpSuggestion: Boolean, + ) { + // Initialize inlay if needed + // Create and show the jump hint manager + jumpHintManager = JumpHintManager(editor, project, lineNumber, startOffset, this) + jumpHintManager?.showIfNeeded() + } + + override fun accept(editor: Editor): Disposable? { + val lineStartOffset = document.getLineStartOffset(lineNumber) + val lineText = document.charsSequence.subSequence(lineStartOffset, document.getLineEndOffset(lineNumber)).toString() + val firstNonWhitespaceOffset = + lineStartOffset + lineText.indexOfFirst { !it.isWhitespace() }.coerceAtLeast(0) + + // Check if the target location is visible (with buffer for line height) + val targetY = editor.offsetToPoint2D(firstNonWhitespaceOffset).y + val visibleArea = editor.scrollingModel.visibleArea + val lineHeight = editor.lineHeight + val isTargetVisible = targetY >= visibleArea.y + lineHeight && targetY <= visibleArea.y + visibleArea.height - lineHeight + + editor.caretModel.moveToOffset(firstNonWhitespaceOffset) + editor.selectionModel.setSelection(firstNonWhitespaceOffset, firstNonWhitespaceOffset) + + // Only scroll if the target is not visible + if (!isTargetVisible) { + // Scroll to maintain the same relative Y position on screen + editor.scrollingModel.disableAnimation() + // MAKE_VISIBLE keeps cursor position, CENTER puts the cursor in the center of the screen + // other tools use CENTER + editor.scrollingModel.scrollToCaret(ScrollType.CENTER) + editor.scrollingModel.enableAnimation() + } + + return null + } + + override fun rejectionCacheKey(): String = "jump_to_edit_offset:$startOffset" + + override fun dispose() { + jumpHintManager = null + editor.component.repaint() + } + } + + /** + * Suggestion that displays multiple ghost text suggestions for separate insertions + */ + data class MultipleGhostTextSuggestion( + override val content: String, + override val startOffset: Int, + override val endOffset: Int, + override val autocomplete_id: String, + val ghostTextSuggestions: List, + ) : AutocompleteSuggestion() { + var initialCursorLine: Int = -1 + override var suggestionAdditions: Int = ghostTextSuggestions.sumOf { it.suggestionAdditions } + override var suggestionDeletions: Int = ghostTextSuggestions.sumOf { it.suggestionDeletions } + + override fun show( + editor: Editor, + isPostJumpSuggestion: Boolean, + ) { + // Track the initial cursor line when the ghost text is first shown + if (initialCursorLine == -1) { + initialCursorLine = editor.caretModel.logicalPosition.line + } + // If any are pure whitespace, set forceHighlight to true for all + val shouldForceHighlight = ghostTextSuggestions.any { it.content.isBlank() } + ghostTextSuggestions.forEach { it.forceHighlight = shouldForceHighlight } + ghostTextSuggestions.forEach { it.show(editor, isPostJumpSuggestion) } + } + + override fun update(editor: Editor): Int? { + val offset = ghostTextSuggestions.firstOrNull()?.update(editor) ?: return null + ghostTextSuggestions.drop(1).forEach { + it.apply { + startOffset += offset + endOffset += offset + updateContentToRender() + // cannot do Disposer.dispose(this) here + dispose() + show(editor) + } + } + return offset + } + + override fun accept(editor: Editor): Disposable? { + val disposables = mutableListOf() + + val sortedSuggestions = ghostTextSuggestions.sortedBy { it.startOffset } + var cumulativeOffset = 0 + + sortedSuggestions.forEach { suggestion -> + if (cumulativeOffset > 0) { + suggestion.startOffset += cumulativeOffset + suggestion.endOffset += cumulativeOffset + } + + suggestion.accept(editor)?.let { disposables.add(it) } + + // Use post-accept content length in case it was adjusted (e.g., trimmed newline) + val insertedLength = suggestion.content.length + cumulativeOffset += insertedLength + } + + return Disposable { + disposables.forEach { Disposer.dispose(it) } + } + } + + override fun dispose() { + ghostTextSuggestions.forEach { Disposer.dispose(it) } + } + } + + companion object { + private val logger = Logger.getInstance(AutocompleteSuggestion::class.java) + private const val MIN_JUMP_DISTANCE = 8 + + /** + * Factory method to create the appropriate suggestion type + * based on the autocomplete response and cursor position + */ + @RequiresEdt + fun fromAutocompleteResponse( + response: NextEditAutocompletion, + editor: Editor, + project: Project, + ): AutocompleteSuggestion? { + var oldContent = + ApplicationManager.getApplication().runReadAction { + editor.document.charsSequence + .subSequence(response.start_index, response.end_index) + .toString() + } + + val caretOffset = editor.caretModel.offset + + val document = editor.document + val documentLength = document.textLength + val caretLine = document.getLineNumber(caretOffset) + val editStartLine = document.getLineNumber(response.start_index) + val lineDifference = abs(caretLine - editStartLine) + + if (lineDifference >= MIN_JUMP_DISTANCE) { + return JumpToEditSuggestion( + oldContent = oldContent, + content = response.completion, + startOffset = response.start_index, + endOffset = response.end_index, + project = project, + originalCompletion = response, + autocomplete_id = response.autocomplete_id, + editor = editor, + ) + } + + val isMultilinePureInsertion = response.completion.contains("\n") && oldContent.isEmpty() + val isCaretAtNewline = + document.text.getOrNull(response.start_index) == '\n' || document.text.getOrNull(response.start_index - 1) == '\n' + + val isMultilineInsertionNonNewline = isMultilinePureInsertion && !isCaretAtNewline + + if (isMultilineInsertionNonNewline) { + // current line + val startLineNumber = document.getLineNumber(response.start_index) + val lineStartOffset = document.getLineStartOffset(startLineNumber) + val lineEndOffset = document.getLineEndOffset(startLineNumber) + oldContent = document.charsSequence.subSequence(lineStartOffset, lineEndOffset).toString() + val relativeStartOffset = response.start_index - lineStartOffset + response.completion = + oldContent.take(relativeStartOffset) + response.completion + oldContent.substring(relativeStartOffset) + response.start_index = lineStartOffset + response.end_index = lineEndOffset + } + + val atEndOfDocument = documentLength == caretOffset + + val ghostText = + if (!isMultilineInsertionNonNewline) { + getGhostTextOrNull( + oldContent, + response.completion, + caretOffset - response.start_index, + atEndOfDocument, + ) + } else { + null + } + + val charsSequence = document.charsSequence + // might hide nes but will try this for now + val shouldHideBlankGhostText = + ghostText?.let { (text, insertOffset) -> + val index = response.start_index + insertOffset + text.isBlank() && index < charsSequence.length && charsSequence[index] == '\n' + } == true + if (shouldHideBlankGhostText) { + return null + } + + val autocompleteSuggestion = + ghostText?.let { (text, insertOffset) -> + val calculatedStartOffset = response.start_index + insertOffset + val (finalText, finalStartOffset) = + adjustGhostTextForEndOfDocument( + text = text, + calculatedStartOffset = calculatedStartOffset, + caretOffset = caretOffset, + charsSequence = charsSequence, + oldContent = oldContent, + ) + // At end of document, if ghost text still starts before caret on the same line after adjustment, + // fall through to popup to avoid rendering issues + val safeOffset = finalStartOffset.coerceIn(0, (documentLength - 1).coerceAtLeast(0)) + val ghostTextLine = document.getLineNumber(safeOffset) + if (atEndOfDocument && finalStartOffset < caretOffset && ghostTextLine == caretLine) { + null + } else { + GhostTextSuggestion( + content = finalText, + startOffset = finalStartOffset, + autocomplete_id = response.autocomplete_id, + document = editor.document, + editor = editor, + ) + } + } ?: getMultipleGhostTextOrNull( + oldContent, + response.completion, + response.start_index, + response.autocomplete_id, + editor.document, + editor, + )?.let { ghostTexts -> + // At end of document with multiple ghost texts on the same line as caret, + // fall through to popup to avoid rendering issues with multiple ghost texts on one line + val maxValidOffset = (documentLength - 1).coerceAtLeast(0) + val ghostTextsOnCaretLine = + ghostTexts.filter { + val safeOffset = it.startOffset.coerceIn(0, maxValidOffset) + document.getLineNumber(safeOffset) == caretLine + } + if (atEndOfDocument && ghostTextsOnCaretLine.size > 1) { + null + } else if (ghostTexts.size > 1) { + MultipleGhostTextSuggestion( + content = response.completion, + startOffset = response.start_index, + endOffset = response.end_index, + autocomplete_id = response.autocomplete_id, + ghostTextSuggestions = ghostTexts, + ) + } else if (ghostTexts.size == 1) { + ghostTexts.first() + } else { + null + } + } ?: run { + PopupSuggestion( + content = response.completion, + startOffset = response.start_index, + endOffset = response.end_index, + oldContent = oldContent, + fileExtension = editor.virtualFile?.extension ?: "txt", + project = project, + autocomplete_id = response.autocomplete_id, + editor = editor, + ) + } + + // Check if autocomplete is a ghost text or multiple ghost texts AND it occurs before the user + // AND the ghost text or one of the multiple ghost texts contains a newline. If so, change it to a tab-to-jump + // BUT only if the change is more than 1 line away (otherwise show popup) + // Note: isOnSingleNewline should NOT trigger at end of document (where there's nothing to block) + val isOnSingleNewline = + document.text.getOrNull(caretOffset - 1) == '\n' && + document.text.getOrNull(caretOffset) != '\n' && + caretOffset < documentLength // Don't trigger at end of document + + // Use safe bounds to avoid IndexOutOfBoundsException when offset is at or beyond document length + val safeMaxOffset = (documentLength - 1).coerceAtLeast(0) + + fun safeGetLineNumber(offset: Int) = document.getLineNumber(offset.coerceIn(0, safeMaxOffset)) + + val shouldConvert = + when (autocompleteSuggestion) { + is GhostTextSuggestion -> { + // Don't convert if ghost text starts on the same line as cursor (it's a continuation, not an edit above) + val isOnSameLine = safeGetLineNumber(autocompleteSuggestion.startOffset) == caretLine + ( + autocompleteSuggestion.startOffset < caretOffset && + autocompleteSuggestion.content.contains( + "\n", + ) && + !isOnSameLine + ) || + (autocompleteSuggestion.startOffset == caretOffset && isOnSingleNewline) + } + + is MultipleGhostTextSuggestion -> { + autocompleteSuggestion.ghostTextSuggestions.any { + val isOnSameLine = safeGetLineNumber(it.startOffset) == caretLine + (it.startOffset < caretOffset && it.content.contains("\n") && !isOnSameLine) || + (it.startOffset == caretOffset && isOnSingleNewline) + } + } + + else -> false + } + + if (shouldConvert) { + if (response.completion.isEmpty()) { + return null + } + if (lineDifference <= 1) { + // Handle edge case where it wants to insert code line above cursor pos - if we don't do this, + // the popup will block the current cursor position + var adjustedContent = response.completion + val adjustedOldContent: String = + oldContent.ifBlank { + val currentLineNumber = document.getLineNumber(caretOffset) + val currentLineStartOffset = document.getLineStartOffset(currentLineNumber) + // use next line start offset to get trailing newline char, but check bounds first + val currentLineEndOffset = + if (currentLineNumber + 1 < document.lineCount) { + document.getLineStartOffset(currentLineNumber + 1) + } else { + // If we're on the last line, use the document end + document.textLength + } + val currentLineText = + document.charsSequence + .subSequence( + currentLineStartOffset, + currentLineEndOffset, + ).toString() + + adjustedContent += oldContent + currentLineText + oldContent + currentLineText + } + + // the removesuffix and -1 is to shift the start offset back by one + return PopupSuggestion( + content = adjustedContent, + startOffset = response.start_index, + endOffset = response.end_index + adjustedOldContent.length, + oldContent = adjustedOldContent, + fileExtension = editor.virtualFile?.extension ?: "txt", + project = project, + autocomplete_id = response.autocomplete_id, + editor = editor, + ) + } else { + return JumpToEditSuggestion( + oldContent = oldContent, + content = response.completion, + startOffset = response.start_index, + endOffset = response.end_index, + project = project, + originalCompletion = response, + autocomplete_id = response.autocomplete_id, + editor = editor, + ) + } + } + + return autocompleteSuggestion + } + + /** + * Returns multiple ghost text suggestions if the change consists of multiple separate insertions, + * or null if it should be handled by other suggestion types + */ + private fun getMultipleGhostTextOrNull( + oldContent: String, + newContent: String, + startOffset: Int, + autocompleteId: String, + document: Document, + editor: Editor, + ): List? { + // TODO: use line based diffs for this + val commonPrefixLength = + if (!oldContent.trim('\n').contains("\n")) oldContent.commonPrefixWith(newContent).length else 0 + val diffGroups = + computeDiffGroups( + oldContent.drop(commonPrefixLength), + newContent.drop(commonPrefixLength), + ).map { it.copy(index = it.index + commonPrefixLength) } + + if (oldContent.isEmpty() || newContent.isEmpty()) { + return null // might be a bandaid + } + + // Only handle if all changes are additions and there are multiple separate insertions + if (!diffGroups.isAllAdditions || diffGroups.size <= 1) { + return null + } + + // Create individual ghost text suggestions for each addition + val ghostTextSuggestions = mutableListOf() + + diffGroups.forEach { diffGroup -> + if (diffGroup.hasAdditions) { + val insertionOffset = startOffset + diffGroup.index + val ghostText = + GhostTextSuggestion( + content = diffGroup.additions, + startOffset = insertionOffset, + autocomplete_id = autocompleteId, + document = document, + editor = editor, + ) + ghostTextSuggestions.add(ghostText) + } + } + + ghostTextSuggestions.forEach { suggestion -> + if (suggestion.content.trimEnd('\n').contains("\n")) { + val charAtOffset = document.text.getOrNull(suggestion.startOffset) + if (charAtOffset != null && charAtOffset != '\n') { + return null + } + } + } + + return if (ghostTextSuggestions.size > 1) ghostTextSuggestions else null + } + + /** + * For pure insertions where ghost text would appear before cursor on the same line, + * adjust to start at cursor position and trim the leading content that's already in the document. + * Only trims if the existing content matches the beginning of the ghost text. + * + * This handles cases like: + * - Cursor at end of document with leading whitespace + * - Cursor at end of line (before trailing newlines) with leading whitespace + */ + private fun adjustGhostTextForEndOfDocument( + text: String, + calculatedStartOffset: Int, + caretOffset: Int, + charsSequence: CharSequence, + oldContent: String, + ): Pair { + // Only adjust for pure insertions where ghost text starts before cursor + if (calculatedStartOffset >= caretOffset || oldContent.isNotEmpty()) { + return Pair(text, calculatedStartOffset) + } + + // The ghost text starts before cursor - check if we can trim the matching prefix + val alreadyTypedLength = caretOffset - calculatedStartOffset + if (alreadyTypedLength >= text.length || alreadyTypedLength <= 0) { + return Pair(text, calculatedStartOffset) + } + + // Get the text that's already in the document between calculatedStartOffset and caretOffset + val existingText = charsSequence.subSequence(calculatedStartOffset, caretOffset).toString() + val ghostTextPrefix = text.substring(0, alreadyTypedLength) + + return if (existingText == ghostTextPrefix) { + // The existing text matches the ghost text prefix - safe to trim + val trimmedText = text.substring(alreadyTypedLength) + Pair(trimmedText, caretOffset) + } else { + // The existing text doesn't match - don't trim, keep original + Pair(text, calculatedStartOffset) + } + } + + private fun getGhostTextOrNull( + oldContent: String, + newContent: String, + caretOffset: Int, + atEndOfDocument: Boolean, + ): Pair? { + val caretInSpan = caretOffset < oldContent.length + if (caretOffset >= 0 && caretInSpan) { + val prefix = oldContent.take(caretOffset) + val suffix = oldContent.drop(caretOffset) + + val newContentContainsPrefixAndSuffix = newContent.startsWith(prefix) && newContent.endsWith(suffix) + val newContentIsLonger = newContent.length > prefix.length + suffix.length + + if (newContentContainsPrefixAndSuffix && newContentIsLonger) { + val addedText = newContent.substring(prefix.length, newContent.length - suffix.length) + if (!addedText.contains("\n")) { + return addedText.takeIf { it.isNotEmpty() }?.let { Pair(it, caretOffset) } + } + } + } + + // Find the best split point by iterating from the longest prefix to shortest + // This ensures we get the cleanest insertion (e.g., ", max_depth=None" instead of "e, max_depth=Non") + for (i in oldContent.length downTo 0) { + val testPrefix = oldContent.take(i) + val testSuffix = oldContent.drop(i) + + if (newContent.startsWith(testPrefix) && + newContent.endsWith(testSuffix) && + testPrefix.length + testSuffix.length <= newContent.length + ) { + val testAddedText = + newContent.substring(testPrefix.length, newContent.length - testSuffix.length) + val caretAtNewline = testPrefix.isEmpty() || testPrefix.last() == '\n' + if (testAddedText.contains("\n") && !caretAtNewline && !atEndOfDocument) { + return null + } + if (testAddedText.isNotEmpty()) { + return Pair(testAddedText, i) + } + } + } + + return null + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/ClearAutocompleteRejectionCacheAction.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/ClearAutocompleteRejectionCacheAction.kt new file mode 100644 index 0000000..c7e6416 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/ClearAutocompleteRejectionCacheAction.kt @@ -0,0 +1,36 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +/** + * Action to clear the autocomplete rejection cache. + * + * This action clears all cached rejected autocomplete suggestions, allowing them to be shown again. + * Useful when you want to see suggestions that were previously rejected. + * + * Default keystroke: Alt+Shift+Backspace (Windows/Linux), Option+Shift+Backspace (Mac) + */ +class ClearAutocompleteRejectionCacheAction : AnAction() { + companion object { + const val ACTION_ID = "com.oxidecode.autocomplete.edit.ClearAutocompleteRejectionCacheAction" + } + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(event: AnActionEvent) { + val project = event.project + event.presentation.isEnabledAndVisible = project != null && !project.isDisposed + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + + // Clear the rejection cache + AutocompleteRejectionCache.getInstance(project).clearCache() + + // Trigger a new autocomplete suggestion + RecentEditsTracker.getInstance(project).processLatestEdit() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteActions.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteActions.kt new file mode 100644 index 0000000..f322a14 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteActions.kt @@ -0,0 +1,95 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project + +/** + * Base class for edit completion actions (accept/reject). + * + * These actions are customizable via IntelliJ's keymap settings. The keystrokes configured + * for these actions are dynamically intercepted at the EditorActionHandler level by + * EditorActionsRouterService, providing both reliability and user customizability. + * + * To customize keystrokes: + * 1. Go to Settings/Preferences → Keymap + * 2. Search for "Accept Edit Completion" or "Reject Edit Completion" + * 3. Assign your preferred keystroke + * 4. The plugin will automatically adapt to your custom keystrokes + */ +abstract class EditCompletionActionBase : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(event: AnActionEvent) { + val project = event.project ?: return + val editor = event.getData(CommonDataKeys.EDITOR) + + val recentEditsTracker = RecentEditsTracker.getInstance(project) + event.presentation.isEnabledAndVisible = editor != null && recentEditsTracker.isCompletionShown + } + + protected abstract fun handleCompletion( + project: Project, + editor: Editor, + ) + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + val editor = event.getData(CommonDataKeys.EDITOR) ?: return + + handleCompletion(project, editor) + } +} + +/** + * Action to accept the current autocomplete suggestion. + * + * Default keystroke: TAB + * + * This action's keystrokes are customizable via the keymap settings. When you change the + * keystroke in Settings → Keymap, the EditorActionsRouterService automatically updates to + * intercept the new keystroke at the low level for reliable autocomplete acceptance. + * + * Note: Any keystroke can be used. When multiple actions are bound to the same key, + * SweepActionPromoter ensures this action takes priority when autocomplete is shown. + */ +class AcceptEditCompletionAction : EditCompletionActionBase() { + companion object { + const val ACTION_ID = "com.oxidecode.autocomplete.edit.AcceptEditCompletionAction" + } + + override fun handleCompletion( + project: Project, + editor: Editor, + ) { + RecentEditsTracker.getInstance(project).acceptSuggestion() + } +} + +/** + * Action to reject the current autocomplete suggestion. + * + * Default keystroke: ESCAPE + * + * This action's keystrokes are customizable via the keymap settings. When you change the + * keystroke in Settings → Keymap, the EditorActionsRouterService automatically updates to + * intercept the new keystroke at the low level for reliable autocomplete rejection. + * + * Note: Any keystroke can be used. When multiple actions are bound to the same key, + * SweepActionPromoter ensures this action takes priority when autocomplete is shown. + */ +class RejectEditCompletionAction : EditCompletionActionBase() { + companion object { + const val ACTION_ID = "com.oxidecode.autocomplete.edit.RejectEditCompletionAction" + } + + override fun handleCompletion( + project: Project, + editor: Editor, + ) { + RecentEditsTracker.getInstance(project).rejectSuggestion() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteModels.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteModels.kt new file mode 100644 index 0000000..b28831d --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteModels.kt @@ -0,0 +1,170 @@ +package com.oxidecode.autocomplete.edit + +import com.oxidecode.data.BaseRequest +import kotlinx.serialization.Serializable +import com.oxidecode.utils.convertPythonToKotlinIndex + +const val MAX_HUNK_SIZE = 10 +const val MAX_TOKEN_COUNT = 8192 +const val AVG_TOKEN_LENGTH = 4 + +enum class AutocompleteDisposeReason { + ACCEPTED, + ESCAPE_PRESSED, + AUTOCOMPLETE_DISPOSED, + EDITOR_LOST_FOCUS, + CLEARING_PREVIOUS_AUTOCOMPLETE, + CARET_POSITION_CHANGED, + EDITOR_FOCUS_CHANGED, + IMPORT_FIX_SHOWN, + LOOKUP_SHOWN, +} + +data class EditRecord( + val originalText: String, + val newText: String, + val filePath: String, + val offset: Int, + val timestamp: Long = System.currentTimeMillis(), +) { + val diff = calculateDiff(originalText, newText) + val formattedDiff = "File: $filePath\n$diff" + val diffHunks: Int = countDiffHunks(diff) + + fun isTooLarge(): Boolean = diff.length > MAX_TOKEN_COUNT * AVG_TOKEN_LENGTH + + fun isNoOpDiff(): Boolean = diff.trim().isEmpty() +} + +data class EditorState( + val documentText: String, + val line: Int, + val cursorOffset: Int, + val filePath: String, + val documentLineCount: Int, + /** Pre-computed line prefix (from line start to cursor) to avoid recomputing from full documentText */ + val currentLinePrefix: String = "", +) { + private val prefix get() = documentText.substring(0, cursorOffset) + private val suffix get() = documentText.substring(cursorOffset) + + fun returnInsertionTextOrNull(newDocumentText: String): String? { + if (newDocumentText.startsWith(prefix) && newDocumentText.endsWith(suffix) && newDocumentText.length >= documentText.length) { + return newDocumentText.removePrefix(prefix).removeSuffix(suffix) + } + return null + } +} + +@Serializable +data class EditorDiagnostic( + val line: Int, + val start_offset: Int, + val end_offset: Int, + val severity: String, + val message: String, + val timestamp: Long = System.currentTimeMillis(), +) + +@Serializable +data class NextEditAutocompleteRequest( + val repo_name: String, + val branch: String? = null, + val file_path: String, + val file_contents: String, + val recent_changes: String, + val cursor_position: Int, + val original_file_contents: String, + val file_chunks: List<@Serializable FileChunk>, + val retrieval_chunks: List<@Serializable FileChunk>, + val recent_user_actions: List<@Serializable UserAction>, + val multiple_suggestions: Boolean = true, + val privacy_mode_enabled: Boolean = false, + val client_ip: String? = null, + val recent_changes_high_res: String, + val changes_above_cursor: Boolean, + val ping: Boolean = false, + val editor_diagnostics: List<@Serializable EditorDiagnostic> = emptyList(), +) : BaseRequest() + +@Serializable +data class NextEditAutocompletion( + var start_index: Int, + var end_index: Int, + var completion: String, + val confidence: Float, + val autocomplete_id: String, +) { + fun adjustIndices(text: String) { + start_index = convertPythonToKotlinIndex(text, start_index) + end_index = convertPythonToKotlinIndex(text, end_index) + } + + fun adjustOffsets(offset: Int) { + start_index += offset + end_index += offset + } + + fun applyChangesTo(original: String): String = original.substring(0, start_index) + completion + original.substring(end_index) +} + +@Serializable +data class NextEditAutocompleteResponse( + // these are unused now, backwards compatibility + var start_index: Int, + var end_index: Int, + val completion: String, + val confidence: Float, + val autocomplete_id: String, + val elapsed_time_ms: Long? = null, + // this is the new completion + var completions: List, +) { + fun adjustIndices(text: String) { + completions.forEach { it.adjustIndices(text) } + } + + fun adjustOffsets(offset: Int) { + completions.forEach { it.adjustOffsets(offset) } + } +} + +data class CursorPositionRecord( + val filePath: String, + val line: Int, + val cursorOffset: Int, + val timestamp: Long = System.currentTimeMillis(), +) + +@Serializable +data class UserAction( + val action_type: UserActionType, + val line_number: Int, // Line number after action is completed + val offset: Int, // Offset after action is completed + val file_path: String, + val timestamp: Long = System.currentTimeMillis(), +) + +enum class UserActionType { + INSERT_CHAR, // Individual character input + INSERT_SELECTION, // Inserting multiple characters (Paste) + DELETE_SELECTION, // Deletion of multiple characters + DELETE_CHAR, // Individual character deletion + UNDO, + REDO, + CURSOR_MOVEMENT, +} + +@Serializable +data class FileChunk( + val file_path: String, + val start_line: Int, + var end_line: Int, + var content: String, + val timestamp: Long = System.currentTimeMillis(), +) { + fun truncate(maxSize: Int) { + end_line = end_line.coerceAtMost(start_line + maxSize) + content = content.lines().take((end_line - start_line + 1).coerceAtLeast(1)).joinToString("\n") + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteUtils.kt new file mode 100644 index 0000000..2bd792f --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditAutocompleteUtils.kt @@ -0,0 +1,144 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.openapi.project.Project +import com.oxidecode.utils.getDiff +import com.oxidecode.utils.readFile + +fun calculateDiff( + originalText: String, + newText: String, + context: Int = 2, +): String = + getDiff( + oldContent = originalText, + newContent = newText, + oldFileName = "", + newFileName = "", + context = context, + ).lines().drop(2).joinToString("\n").trim('\n') + +fun countAddedAndDeletedLines(diff: String): Pair { + var addedLines = 0 + var deletedLines = 0 + + diff.lines().forEach { line -> + when { + line.startsWith("+") && !line.startsWith("+++") -> addedLines++ + line.startsWith("-") && !line.startsWith("---") -> deletedLines++ + } + } + + return Pair(addedLines, deletedLines) +} + +fun shouldCombineWithPreviousEdit( + previousEdit: EditRecord?, + currentEdit: EditRecord, +): Boolean { + if (previousEdit == null) return false + if (previousEdit.filePath != currentEdit.filePath) return false + + val diffBetweenEdits = calculateDiff(previousEdit.originalText, currentEdit.newText) + val diffBetweenCurrentEdit = calculateDiff(previousEdit.newText, currentEdit.newText) + + if (getMaxChangeSize(diffBetweenEdits) > MAX_HUNK_SIZE) return false + if (getMaxChangeSize(diffBetweenCurrentEdit) > MAX_HUNK_SIZE) return false + + val diffHunks = countDiffHunks(diffBetweenEdits) + return diffHunks <= previousEdit.diffHunks +} + +fun countDiffHunks(diff: String): Int { + // Count the number of diff hunks by looking for "@@ " markers + return diff.split("\n").count { it.startsWith("@@ ") } +} + +fun getMaxChangeSize(diff: String): Int { + val lines = diff.split("\n") + var currentHunkLines = 0 + + for (line in lines) { + when { + line.startsWith("+") && !line.startsWith("+++") -> currentHunkLines++ + line.startsWith("-") && !line.startsWith("---") -> currentHunkLines++ + } + } + + return currentHunkLines +} + +/** + * Fuses and deduplicates touching snippets from the same file while preserving order. + * Two snippets are considered "touching" if they're from the same file and their line ranges + * are adjacent or overlapping. + */ +fun fuseAndDedupSnippets( + project: Project, + snippets: List, +): List { + if (snippets.isEmpty()) return emptyList() + + val result = mutableListOf() + + snippetsLoop@ for (snippet in snippets) { + for (i in result.indices) { + val existing = result[i] + if (existing.file_path == snippet.file_path && + ( + (snippet.end_line >= existing.end_line && existing.end_line >= snippet.start_line) || + (snippet.end_line >= existing.start_line && existing.start_line >= snippet.start_line) + ) + ) { + val mergedStartLine = minOf(existing.start_line, snippet.start_line) + val mergedEndLine = maxOf(existing.end_line, snippet.end_line) + + val fileContent = readFile(project, existing.file_path) + val mergedContent: String = + fileContent?.let { + val lines = it.lines() + val startIndex = maxOf(0, mergedStartLine - 1) + val endIndex = minOf(lines.size - 1, mergedEndLine - 1) + lines.subList(startIndex, endIndex + 1).joinToString("\n") + } ?: ( + if (existing.start_line <= snippet.start_line) { + existing.content + "\n" + snippet.content + } else { + snippet.content + "\n" + existing.content + } + ) + + result[i] = + FileChunk( + file_path = existing.file_path, + start_line = mergedStartLine, + end_line = mergedEndLine, + content = mergedContent, + timestamp = maxOf(existing.timestamp, snippet.timestamp), + ) + continue@snippetsLoop + } + } + + result.add(snippet) + } + + return result +} + +fun isFileTooLarge( + fileContent: String, + project: Project, +): Boolean { + if (fileContent.length > 10_000_000) { + return true + } + val lines = fileContent.lines() + if (lines.size > 50_000) { + return true + } + val avgLineLengthThreshold = 240 + if (fileContent.length / (lines.size + 1) > avgLineLengthThreshold) { + return true + } + return false +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditorActionsRouterService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditorActionsRouterService.kt new file mode 100644 index 0000000..dcb4156 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EditorActionsRouterService.kt @@ -0,0 +1,386 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.ide.IdeEventQueue +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.IdeActions +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.EditorActionHandler +import com.intellij.openapi.editor.actionSystem.EditorActionManager +import com.intellij.openapi.editor.impl.EditorComponentImpl +import com.intellij.openapi.keymap.KeymapManagerListener +import com.intellij.openapi.project.Project +import com.oxidecode.settings.OxideCodeSettings +import com.oxidecode.utils.OxideCodeConstants +import com.oxidecode.utils.getKeyStrokesForAction +import com.oxidecode.utils.parseKeyStrokesToPrint +import java.awt.AWTEvent +import java.awt.Component +import java.awt.event.KeyEvent + +/** + * Application-level router that installs EditorActionManager handlers once + * and delegates behavior to the appropriate project's RecentEditsTracker. + * + * This service dynamically intercepts EditorActions based on the user's keymap configuration + * for AcceptEditCompletionAction and RejectEditCompletionAction, allowing users to customize + * the keystrokes used for accepting/rejecting autocomplete suggestions. + */ +@Service(Service.Level.APP) +class EditorActionsRouterService : Disposable { + private val originals: MutableMap = mutableMapOf() + + // When TAB acceptance happens, Swing can still deliver a subsequent KEY_TYPED '\t' event. + // In Gateway split mode this is the main cause of the "accepted suggestion + extra tab inserted" bug. + // We suppress that follow-up typed TAB for a very short window. + @Volatile + private var gatewayClientSuppressTabTypedUntilMs: Long = 0 + + // Gateway split mode note: + // Even if we intercept the editor action handler, TAB can still be processed as a regular key event + // and forwarded/applied, causing an extra tab after acceptance. + // + // On the Gateway CLIENT we add an IdeEventQueue dispatcher that consumes the raw TAB key event + // when a Sweep suggestion is visible. + private val gatewayClientTabDispatcher: IdeEventQueue.EventDispatcher = + IdeEventQueue.EventDispatcher dispatcher@{ e: AWTEvent -> + if (OxideCodeConstants.GATEWAY_MODE != OxideCodeConstants.GatewayMode.CLIENT) return@dispatcher false + + val ke = e as? KeyEvent ?: return@dispatcher false + if (ke.isConsumed) return@dispatcher true + + // Suppress the follow-up KEY_TYPED tab after we accepted via TAB. + if (ke.id == KeyEvent.KEY_TYPED) { + val now = System.currentTimeMillis() + val isTypedTab = ke.keyChar == '\t' && !ke.isAltDown && !ke.isControlDown && !ke.isMetaDown && !ke.isShiftDown + if (isTypedTab && now < gatewayClientSuppressTabTypedUntilMs) { + ke.consume() + return@dispatcher true + } + return@dispatcher false + } + + if (ke.id != KeyEvent.KEY_PRESSED) return@dispatcher false + + val isPlainTab = + ke.keyCode == KeyEvent.VK_TAB && + !ke.isAltDown && + !ke.isControlDown && + !ke.isMetaDown && + !ke.isShiftDown + + if (!isPlainTab) return@dispatcher false + + // Only if TAB is the configured accept editor action + if (IdeActions.ACTION_EDITOR_TAB !in activeAcceptActions) return@dispatcher false + + fun findEditorComponent(component: Component?): EditorComponentImpl? { + var c = component + while (c != null) { + if (c is EditorComponentImpl) return c + c = c.parent + } + return null + } + + val editorComponent = findEditorComponent(ke.component) ?: return@dispatcher false + val editor = editorComponent.editor + val tracker = trackerFor(editor) ?: return@dispatcher false + if (!tracker.isCompletionShown) return@dispatcher false + + tracker.acceptSuggestion() + gatewayClientSuppressTabTypedUntilMs = System.currentTimeMillis() + 250 + ke.consume() + true + } + + // Cache of currently active accept/reject action IDs (updated when keymap changes) + @Volatile + private var activeAcceptActions: Set = emptySet() + + @Volatile + private var activeRejectActions: Set = emptySet() + + private var lastAcceptKeystrokes: String = "" + private var lastRejectKeystrokes: String = "" + + companion object { + fun getInstance(): EditorActionsRouterService = + ApplicationManager + .getApplication() + .getService(EditorActionsRouterService::class.java) + + private const val ACCEPT_ACTION_ID = AcceptEditCompletionAction.ACTION_ID + private const val REJECT_ACTION_ID = "com.oxidecode.autocomplete.edit.RejectEditCompletionAction" + } + + init { + // Initialize baseline for telemetry + lastAcceptKeystrokes = getKeystrokesString(ACCEPT_ACTION_ID) + lastRejectKeystrokes = getKeystrokesString(REJECT_ACTION_ID) + + // Update active actions cache + updateActiveActions() + + // Install all possible handlers once + installHandlers() + + // In Gateway split mode, also consume the raw TAB event on the CLIENT so it doesn't + // fall through to the normal indent/tab handler. + if (OxideCodeConstants.GATEWAY_MODE == OxideCodeConstants.GatewayMode.CLIENT) { + IdeEventQueue.getInstance().addDispatcher(gatewayClientTabDispatcher, this) + } + + // Listen for keymap changes and update the cache (no reinstall needed) + ApplicationManager + .getApplication() + .messageBus + .connect(this) + .subscribe( + KeymapManagerListener.TOPIC, + object : KeymapManagerListener { + override fun activeKeymapChanged(keymap: com.intellij.openapi.keymap.Keymap?) { + checkAndTrackKeystrokeChanges() + updateActiveActions() + } + }, + ) + } + + /** + * Updates the cached set of active accept/reject action IDs based on current keymap. + * Called on init and whenever keymap changes. No handler reinstallation needed. + */ + private fun updateActiveActions() { + activeAcceptActions = + getKeyStrokesForAction(ACCEPT_ACTION_ID) + .let { KeystrokeToEditorActionMapper.mapToEditorActions(it) } + .toSet() + + activeRejectActions = + getKeyStrokesForAction(REJECT_ACTION_ID) + .let { KeystrokeToEditorActionMapper.mapToEditorActions(it) } + .toSet() + } + + /** + * Installs all possible action handlers once. + * Handlers check activeAcceptActions/activeRejectActions at runtime to determine behavior. + */ + private fun installHandlers() { + val eam = EditorActionManager.getInstance() + + fun wrap( + actionId: String, + wrapper: (EditorActionHandler) -> EditorActionHandler, + ) { + if (originals.containsKey(actionId)) return + val original = eam.getActionHandler(actionId) + originals[actionId] = original + eam.setActionHandler(actionId, wrapper(original)) + } + + // Install handlers for ALL possible EditorActions that could be bound to accept/reject. + // At runtime, we check activeAcceptActions/activeRejectActions to determine behavior. + // Note: Only include EditorActions here, not regular Actions (like CODE_COMPLETION) + val allPossibleActions = + listOf( + IdeActions.ACTION_EDITOR_TAB, + IdeActions.ACTION_EDITOR_UNINDENT_SELECTION, + IdeActions.ACTION_EDITOR_ENTER, + IdeActions.ACTION_EDITOR_ESCAPE, + IdeActions.ACTION_EDITOR_MOVE_CARET_RIGHT, + IdeActions.ACTION_EDITOR_MOVE_CARET_LEFT, + IdeActions.ACTION_EDITOR_MOVE_CARET_UP, + IdeActions.ACTION_EDITOR_MOVE_CARET_DOWN, + IdeActions.ACTION_EDITOR_MOVE_CARET_RIGHT_WITH_SELECTION, + IdeActions.ACTION_EDITOR_DELETE, + IdeActions.ACTION_EDITOR_BACKSPACE, + IdeActions.ACTION_EDITOR_NEXT_WORD, + IdeActions.ACTION_EDITOR_PREVIOUS_WORD, + IdeActions.ACTION_EDITOR_MOVE_LINE_END, + IdeActions.ACTION_EDITOR_MOVE_LINE_START, + ) + + // Wrap all possible actions with runtime checks + allPossibleActions.forEach { actionId -> + wrap(actionId) { original -> + object : EditorActionHandler() { + override fun doExecute( + editor: Editor, + caret: Caret?, + dataContext: DataContext, + ) { + // Guard: don't process actions for disposed editors/projects + if (editor.isDisposed || editor.project?.isDisposed == true) { + return + } + + // In Gateway split mode, editor keystrokes are applied on both frontend and backend. + // If we swallow the action on the HOST (backend), the backend state won't change and + // the editor will quickly revert due to state synchronization. + // + // Therefore: + // - On HOST: never intercept accept/reject; always execute original behavior. + // - On CLIENT/NA: intercept based on user's keymap. + if (OxideCodeConstants.GATEWAY_MODE == OxideCodeConstants.GatewayMode.HOST) { + original.execute(editor, caret, dataContext) + return + } + + if (true) { + val tracker = trackerFor(editor) + + // Special case: Alt-Right (Next word) with acceptWordOnRightArrow setting + if (actionId == IdeActions.ACTION_EDITOR_NEXT_WORD && + OxideCodeSettings.getInstance().acceptWordOnRightArrow && + tracker?.acceptNextWord() == true + ) { + return + } + + if (tracker == null) { + original.execute(editor, caret, dataContext) + return + } + + // Runtime check: Is this action bound to accept? + if (actionId in activeAcceptActions && tracker.isCompletionShown) { + tracker.acceptSuggestion() + return + } + + // Runtime check: Is this action bound to reject? + if (actionId in activeRejectActions && tracker.isCompletionShown) { + tracker.rejectSuggestion() + return + } + + // Not bound to accept/reject, or no completion shown - execute original + original.execute(editor, caret, dataContext) + } else { + return + } + } + + override fun isEnabledForCaret( + editor: Editor, + caret: Caret, + dataContext: DataContext, + ): Boolean = originals[actionId]?.isEnabled(editor, caret, dataContext) ?: true + } + } + } + + wrap(IdeActions.ACTION_CHOOSE_LOOKUP_ITEM_REPLACE) { original -> + object : EditorActionHandler() { + override fun doExecute( + editor: Editor, + caret: Caret?, + dataContext: DataContext, + ) { + // Guard: don't process actions for disposed editors/projects + if (editor.isDisposed || editor.project?.isDisposed == true) { + return + } + + // See comment above: on HOST we must execute original to avoid frontend state reverting. + if (OxideCodeConstants.GATEWAY_MODE == OxideCodeConstants.GatewayMode.HOST) { + original.execute(editor, caret, dataContext) + return + } + + if (true) { + val tracker = trackerFor(editor) ?: return original.execute(editor, caret, dataContext) + + // Only intercept if TAB is configured as the accept key + if (tracker.isCompletionShown && IdeActions.ACTION_EDITOR_TAB in activeAcceptActions) { + tracker.acceptSuggestion() + } else { + original.execute(editor, caret, dataContext) + } + } else { + return + } + } + + override fun isEnabledForCaret( + editor: Editor, + caret: Caret, + dataContext: DataContext, + ): Boolean = originals[IdeActions.ACTION_CHOOSE_LOOKUP_ITEM_REPLACE]?.isEnabled(editor, caret, dataContext) ?: true + } + } + + // We only mark metadata for first-time lookup usage; we do not change behavior + wrap(IdeActions.ACTION_CHOOSE_LOOKUP_ITEM) { original -> + object : EditorActionHandler() { + override fun doExecute( + editor: Editor, + caret: Caret?, + dataContext: DataContext, + ) { + // Guard: don't process actions for disposed editors/projects + if (editor.isDisposed || editor.project?.isDisposed == true) { + return + } + + original.execute(editor, caret, dataContext) + } + + override fun isEnabledForCaret( + editor: Editor, + caret: Caret, + dataContext: DataContext, + ): Boolean = originals[IdeActions.ACTION_CHOOSE_LOOKUP_ITEM]?.isEnabled(editor, caret, dataContext) ?: true + } + } + + // Note: All accept/reject keybindings are now handled dynamically via the allPossibleActions loop above. + // Only the keybindings explicitly configured by the user will trigger accept/reject behavior. + // Special cases (caret movement rejection, Alt-Right accept word) are also handled in that loop. + } + + private fun checkAndTrackKeystrokeChanges() { + val currentAccept = getKeystrokesString(ACCEPT_ACTION_ID) + if (currentAccept != lastAcceptKeystrokes) { + lastAcceptKeystrokes = currentAccept + } + + val currentReject = getKeystrokesString(REJECT_ACTION_ID) + if (currentReject != lastRejectKeystrokes) { + lastRejectKeystrokes = currentReject + } + } + + private fun getKeystrokesString(actionId: String): String = + getKeyStrokesForAction(actionId) + .mapNotNull { parseKeyStrokesToPrint(it) } + .sorted() + .joinToString(", ") + + private fun trackerFor(editor: Editor): RecentEditsTracker? { + val project: Project = editor.project ?: return null + // Only delegate if the feature is enabled to avoid instantiating trackers unnecessarily + return if (OxideCodeSettings.getInstance().nextEditPredictionFlagOn) { + RecentEditsTracker.getInstance(project) + } else { + null + } + } + + override fun dispose() { + // Restore original handlers on the EDT to leave IDE state clean on plugin unload + val app = ApplicationManager.getApplication() + app.invokeLater { + val eam = EditorActionManager.getInstance() + originals.forEach { (id, original) -> + eam.setActionHandler(id, original) + } + originals.clear() + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EntityUsageSearchService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EntityUsageSearchService.kt new file mode 100644 index 0000000..2aea0de --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/EntityUsageSearchService.kt @@ -0,0 +1,771 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupManager +import com.intellij.codeInsight.lookup.impl.LookupImpl +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.PsiSearchHelper +import com.intellij.util.concurrency.AppExecutorUtil +import com.oxidecode.utils.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class EntityUsageSearchService( + private val project: Project, +) { + companion object { + private val logger = Logger.getInstance(EntityUsageSearchService::class.java) + const val MAX_SEARCH_TIMEOUT_MS = 30L + const val MAX_DEFINITION_RESOLUTION_TIMEOUT_MS = 500L // 500ms timeout for resolving definitions + const val ENTITY_USAGE_CONTEXT_LINES_ABOVE = 9 + const val ENTITY_USAGE_CONTEXT_LINES_BELOW = 9 + const val MAX_SEARCH_RESULTS_PER_TERM = 100 + const val MAX_TERMS_TO_SEARCH = 5 + const val LINES_TO_SEARCH = 3 + const val CACHE_TTL_MS = 30_000L // 30 seconds + const val CACHE_MAX_SIZE = 128 + const val MAX_DROPDOWN_ITEMS = 10 + const val MAX_DROPDOWN_TIMEOUT_MS = 30L + } + + private val numDefinitionsToFetch: Int = 6 + + private val numUsagesToFetch: Int = 6 + + // Cache for individual term results - key is single term, value is the found occurrences for that term + private val termCache = + LRUCache>>( + maxSize = CACHE_MAX_SIZE, + ttlMs = CACHE_TTL_MS, + ) + + private data class FoundOccurrence( + val filePath: String, + val lineNumbers: List, + val lastUpdateTime: Long, + val fileType: FileType, + ) + + private enum class FileType( + val priority: Int, + ) { + PROJECT(1), + TEST(2), + EXCLUDED(3), + EXTERNAL(4), + } + + private fun processElementAtOffset( + targetOffset: Int, + psiFile: com.intellij.psi.PsiFile, + processedElements: MutableSet, + fileChunks: MutableList, + ): Boolean { + return try { + val elementAtCursor = psiFile.findElementAt(targetOffset) ?: return false + + // Skip if the element text is a language keyword + val elementText = elementAtCursor.text?.trim() + if (elementText != null && isLanguageKeyword(elementText, psiFile)) { + logger.debug("Skipping language keyword: $elementText") + return false + } + + val reference = elementAtCursor.reference ?: elementAtCursor.parent?.reference + val targetElement = + try { + reference?.resolve() + } catch (t: Throwable) { + // Fail silently to avoid surfacing resolver exceptions from language plugins (e.g., TS) + // Log at debug level only - these are expected errors from language plugins with stale indices + logger.debug("Failed to resolve reference at offset $targetOffset in ${psiFile.virtualFile?.path}", t) + return false + } ?: return false + + val elementKey = "${targetElement.containingFile?.virtualFile?.path}:${System.identityHashCode(targetElement)}" + if (processedElements.contains(elementKey)) return false + + processedElements.add(elementKey) + + val targetFile = targetElement.containingFile + val filePath = + relativePath(project, targetFile?.virtualFile?.path ?: "") + ?: targetFile?.virtualFile?.path ?: "unknown" + + // Computing the actual lines here is very slow, so we just use the lines count + +// val targetDocument = +// targetFile?.virtualFile?.let { +// FileDocumentManager.getInstance().getDocument(it) +// } +// val startLine = +// if (targetDocument != null) { +// targetDocument.getLineNumber(targetElement.textOffset) + 1 +// } else { +// 1 +// } + + // Safely get the element text, catching any potential errors + val definitionText = + try { + targetElement.text + } catch (e: Throwable) { + // If getting text fails, skip this element + return false + } + + if (definitionText.isEmpty()) return false + + val lines = definitionText.lines() +// val endLine = startLine + maxOf(0, lines.size - 1) + + fileChunks.add( + FileChunk( + file_path = filePath, + start_line = 1, + end_line = lines.size, + content = definitionText, + timestamp = System.currentTimeMillis(), + ), + ) + true + } catch (t: Throwable) { + // Any unexpected resolver/PSI error should be ignored to keep autocomplete robust + false + } + } + + /** + * Gets the definition text of the past n elements before the cursor position. + * Uses IntelliJ's PSI APIs for maximum compatibility across all languages. + */ + fun getDefinitionsBeforeCursor(currentEditorState: EditorState): List = + runCatching { + // Cache the feature flag value once at the start to avoid repeated lookups + val maxDefinitions = numDefinitionsToFetch + + val future: Future> = + AppExecutorUtil.getAppExecutorService().submit> { + ReadAction.computeCancellable, Exception> { + val editor = + FileEditorManager.getInstance(project).selectedTextEditor + ?: return@computeCancellable emptyList() + val document = editor.document + val psiFile = + PsiDocumentManager.getInstance(project).getPsiFile(document) + ?: return@computeCancellable emptyList() + + val documentText = document.charsSequence + var targetOffset = maxOf(0, currentEditorState.cursorOffset - 1) + val fileChunks = mutableListOf() + val processedElements = mutableSetOf() // To avoid duplicates + + var elementsFound = 0 + val cursorOffset = currentEditorState.cursorOffset + val currentLineNumber = document.getLineNumber(cursorOffset) + val currentLineStart = document.getLineStartOffset(currentLineNumber) + val currentLineEnd = document.getLineEndOffset(currentLineNumber) + + // Phase 1: Walk from cursor to start of line + targetOffset = cursorOffset - 1 + while (targetOffset >= currentLineStart && elementsFound < maxDefinitions) { + // Skip whitespace and symbols + while (targetOffset >= currentLineStart) { + val char = documentText[targetOffset] + if (char.isWhitespace() || char in "(){}[]<>,.;:=+-*/%!&|^~?") { + targetOffset-- + } else { + break + } + } + + if (targetOffset < currentLineStart) break + + if (processElementAtOffset(targetOffset, psiFile, processedElements, fileChunks)) { + elementsFound++ + } + + // Skip to the start of the current word to avoid redundant checks + while (targetOffset >= currentLineStart) { + val char = documentText[targetOffset] + if (char.isWhitespace() || char in "(){}[]<>,.;:=+-*/%!&|^~?") { + break + } + targetOffset-- + } + } + + // Phase 2: Walk from cursor to end of line + targetOffset = cursorOffset + while (targetOffset < currentLineEnd && elementsFound < maxDefinitions) { + // Skip whitespace and symbols + while (targetOffset < currentLineEnd) { + val char = documentText[targetOffset] + if (char.isWhitespace() || char in "(){}[]<>,.;:=+-*/%!&|^~?") { + targetOffset++ + } else { + break + } + } + + if (targetOffset >= currentLineEnd) break + + if (processElementAtOffset(targetOffset, psiFile, processedElements, fileChunks)) { + elementsFound++ + } + + // Skip to the end of the current word to avoid redundant checks + while (targetOffset < currentLineEnd) { + val char = documentText[targetOffset] + if (char.isWhitespace() || char in "(){}[]<>,.;:=+-*/%!&|^~?") { + break + } + targetOffset++ + } + } + + // Phase 3: Walk upwards line by line (max 6 non-whitespace lines) + var lineNumber = currentLineNumber - 1 + var nonWhitespaceLinesProcessed = 0 + while (lineNumber >= 0 && nonWhitespaceLinesProcessed < 6 && elementsFound < maxDefinitions) { + val lineStart = document.getLineStartOffset(lineNumber) + val lineEnd = document.getLineEndOffset(lineNumber) + + var processElementCalled = false // Track if we called processElementAtOffset on this line + targetOffset = lineStart + while (targetOffset < lineEnd && elementsFound < maxDefinitions) { + // Skip whitespace and symbols + while (targetOffset < lineEnd) { + val char = documentText[targetOffset] + if (char.isWhitespace() || char in "(){}[]<>,.;:=+-*/%!&|^~?") { + targetOffset++ + } else { + break + } + } + + if (targetOffset >= lineEnd) break + + processElementCalled = true + if (processElementAtOffset(targetOffset, psiFile, processedElements, fileChunks)) { + elementsFound++ + } + + // Skip to the end of the current word to avoid redundant checks + while (targetOffset < lineEnd) { + val char = documentText[targetOffset] + if (char.isWhitespace() || char in "(){}[]<>,.;:=+-*/%!&|^~?") { + break + } + targetOffset++ + } + } + + // Only count this line if we called processElementAtOffset at least once + if (processElementCalled) { + nonWhitespaceLinesProcessed++ + } + + lineNumber-- + } + + fileChunks + } + } + + // Wait for the result with timeout + try { + future.get(MAX_DEFINITION_RESOLUTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: Throwable) { + // Timeout or other error - cancel the future and return empty list + // Use cancel(false) to avoid interrupting the thread during PSI operations/index updates + future.cancel(false) + emptyList() + } + }.getOrDefault(emptyList()) + + /** + * Finds occurrences of text from the current line where the cursor is positioned. + * This provides additional context for autocomplete by including relevant code references. + */ + fun getCurrentLineEntityUsages(currentEditorState: EditorState): List { + val e2eStartTime = System.currentTimeMillis() + val currentFilePath = relativePath(project, currentEditorState.filePath) ?: currentEditorState.filePath + + try { + val usageChunks = mutableListOf() + + // Only wrap the file/document access operations in ReadAction + val (virtualFile, document) = + runCatching { + ReadAction + .computeCancellable, Exception> { + val vf = FileEditorManager.getInstance(project).selectedFiles.firstOrNull() + val doc = vf?.let { FileDocumentManager.getInstance().getDocument(it) } + Pair(vf, doc) + } + }.getOrDefault(Pair(null, null)) + + if (virtualFile == null || document == null) return emptyList() + + // Get the text from the last few lines including the current line + val textBeforeCursor = + currentEditorState.documentText.substring( + 0, + currentEditorState.cursorOffset.coerceAtMost(currentEditorState.documentText.length), + ) + val currentLineNumber = textBeforeCursor.count { it == '\n' } + + if (currentLineNumber >= document.lineCount) return emptyList() + + val startLineNumber = maxOf(0, currentLineNumber - LINES_TO_SEARCH + 1) + val endLineNumber = currentLineNumber - 1 + + val searchText = + buildString { + // Add previous lines first + for (lineNum in startLineNumber..endLineNumber) { + if (lineNum >= document.lineCount) break + appendLineText(document, lineNum) + } + + // Explicitly add current line at the end + if (currentLineNumber < document.lineCount) { + appendLineText(document, currentLineNumber) + } + } + + val lineText = + textBeforeCursor + .trimEnd() + .lines() + .lastOrNull() + ?.trim() ?: "" + + // Determine appropriate keywords based on current file extension + val currentFileExtension = currentEditorState.filePath.substringAfterLast('.', "") + val language = OxideCodeConstants.EXTENSION_TO_LANGUAGE[currentFileExtension] + val relevantKeywords = language?.let { OxideCodeConstants.LANGUAGE_KEYWORDS[it] } ?: emptyList() + + val candidateTerms = + searchText + .replace(Regex(OxideCodeConstants.COMMON_SYMBOLS_REGEX), " ") // Remove common symbols + .split("\\s+".toRegex()) + .filter { term -> + term.length >= 3 && + !term.matches(Regex("\\d+")) && + // Skip pure numbers + !relevantKeywords.contains(term.lowercase()) + }.distinct() + .takeLast(MAX_TERMS_TO_SEARCH * 3) // 15 terms + + if (candidateTerms.isEmpty()) return emptyList() + + // Prioritize rare/specific terms over common ones using codebase-level frequency analysis + val searchTerms = + try { + // Sort by complexity score + sortByTermComplexity(candidateTerms).take(MAX_TERMS_TO_SEARCH) + } catch (e: Exception) { + // Fallback to original behavior if frequency analysis fails + logger.warn("Failed to analyze term frequencies, using fallback", e) + candidateTerms.takeLast(MAX_TERMS_TO_SEARCH) + } + + if (searchTerms.isEmpty()) return emptyList() + + val foundOccurrences = + runCatching { + val cancelled = AtomicBoolean(false) + val partialResults = ConcurrentHashMap>() + + val searchFuture: Future>> = + AppExecutorUtil.getAppExecutorService().submit>> { + ReadAction.computeCancellable>, Exception> { + val searchHelper = PsiSearchHelper.getInstance(project) + val scope = GlobalSearchScope.projectScope(project) + + val reversedSearchTerms = searchTerms.reversed() + + for (searchTerm in reversedSearchTerms) { + // Check cache first for this term + if (cancelled.get()) { + break + } + + val cachedResults = termCache.get(searchTerm) + if (cachedResults != null) { + for ((filePath, lineNumbers) in cachedResults) { + partialResults.getOrPut(filePath) { mutableListOf() }.addAll(lineNumbers) + } + continue + } + + var filesProcessed = 0 + var termResultCount = 0 + val termOccurrences = + mutableMapOf>() // Temporary storage for this term + + try { + searchHelper.processAllFilesWithWord( + searchTerm, + scope, + { psiFile -> + if (cancelled.get()) { + return@processAllFilesWithWord false + } + + filesProcessed++ + if (usageChunks.size >= 3) { + return@processAllFilesWithWord false // Limit total chunks + } + + val fileVirtualFile = + psiFile.virtualFile ?: return@processAllFilesWithWord true + val fileRelativePath = + relativePath(project, fileVirtualFile.path) + ?: fileVirtualFile.path + + // Skip current file + if (fileRelativePath == currentFilePath) return@processAllFilesWithWord true + + // Filter for same file extension + val currentFileExtension = currentFilePath.substringAfterLast('.', "") + val fileExtension = fileRelativePath.substringAfterLast('.', "") + if (currentFileExtension.isNotEmpty() && fileExtension != currentFileExtension) { + return@processAllFilesWithWord true + } + + val fileDocument = + FileDocumentManager + .getInstance() + .getDocument(fileVirtualFile) + ?: return@processAllFilesWithWord true + val fileText = fileDocument.text + + // Find line numbers containing this search term + val lines = fileText.lines() + var matchesInFile = 0 + for ((lineIndex, line) in lines.withIndex()) { + if (line.contains(searchTerm, ignoreCase = false)) { + termOccurrences + .getOrPut(fileRelativePath) { mutableListOf() } + .add(lineIndex + 1) // Convert to 1-based + matchesInFile++ + termResultCount++ + } + } + + if (termResultCount >= MAX_SEARCH_RESULTS_PER_TERM) { + return@processAllFilesWithWord false + } + true + }, + true, + ) + } catch (t: Throwable) { + cancelled.set(true) + return@computeCancellable partialResults + } + + // Always add occurrences, but limit to first MAX_SEARCH_RESULTS_PER_TERM results + val limitedOccurrences = mutableMapOf>() + var totalAdded = 0 + + for ((filePath, lineNumbers) in termOccurrences) { + if (totalAdded >= MAX_SEARCH_RESULTS_PER_TERM) break + + val remainingSlots = MAX_SEARCH_RESULTS_PER_TERM - totalAdded + val linesToAdd = lineNumbers.take(remainingSlots) + + if (linesToAdd.isNotEmpty()) { + limitedOccurrences.getOrPut(filePath) { mutableListOf() }.addAll(linesToAdd) + totalAdded += linesToAdd.size + } + } + + for ((filePath, lineNumbers) in limitedOccurrences) { + partialResults.getOrPut(filePath) { mutableListOf() }.addAll(lineNumbers) + } + + // Cache individual term results + if (limitedOccurrences.isNotEmpty()) { + termCache.put(searchTerm, limitedOccurrences.toMutableMap()) + } + } + + partialResults + } + } + + // Poll for completion or timeout + val result = + try { + searchFuture.get(MAX_SEARCH_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: Throwable) { + cancelled.set(true) + searchFuture.cancel(false) + partialResults + } + + result + }.getOrDefault(mutableMapOf()) + + val processingStartTime = System.currentTimeMillis() + val foundOccurrencesList = + foundOccurrences.map { (fileRelativePath, lineNumbers) -> + val lastUpdateTime = getFileLastUpdateTime(fileRelativePath) + FoundOccurrence( + filePath = fileRelativePath, + lineNumbers = lineNumbers.distinct().sorted(), + lastUpdateTime = lastUpdateTime, + fileType = FileType.PROJECT, + ) + } + + val sortedOccurrences = + foundOccurrencesList + .sortedWith( + compareBy { it.fileType.priority } + .thenByDescending { it.lastUpdateTime }, + ).take(10) + + val bannedLinesByFile = mutableMapOf>() + + for (occurrence in sortedOccurrences) { + val fileContent = readFile(project, occurrence.filePath) ?: continue + val lines = fileContent.lines() + val bannedLines = bannedLinesByFile.getOrPut(occurrence.filePath) { mutableSetOf() } + + for (lineNum in occurrence.lineNumbers) { + if (bannedLines.contains(lineNum)) continue + + val startLine = maxOf(1, lineNum - ENTITY_USAGE_CONTEXT_LINES_ABOVE) + val endLine = minOf(lines.size, lineNum + ENTITY_USAGE_CONTEXT_LINES_BELOW) + + val chunkLines = mutableListOf() + for (contextLine in startLine..endLine) { + chunkLines.add(lines[contextLine - 1]) + } + val chunkContent = chunkLines.joinToString("\n") + + usageChunks.add( + FileChunk( + file_path = occurrence.filePath, + start_line = startLine, + end_line = endLine, + content = chunkContent, + timestamp = System.currentTimeMillis(), + ), + ) + + // Ban all lines within this context window to prevent overlaps + for (contextLine in startLine..endLine) { + bannedLines.add(contextLine) + } + } + } + + val sortedUsageChunks = + usageChunks.sortedBy { chunk -> + val chunkLines = chunk.content.lines() + val mainLineIndex = ENTITY_USAGE_CONTEXT_LINES_ABOVE.coerceAtMost(chunkLines.size - 1) + val mainLine = + if (chunkLines.isNotEmpty() && mainLineIndex < chunkLines.size) { + chunkLines[mainLineIndex].trim() + } else { + chunk.content.trim() + } + StringDistance.levenshteinDistance(lineText, mainLine) + } + + // Cache the feature flag value to avoid repeated lookups + val maxUsages = numUsagesToFetch + return sortedUsageChunks.take(maxUsages) + } catch (e: Exception) { + return emptyList() + } + } + + private fun StringBuilder.appendLineText( + document: com.intellij.openapi.editor.Document, + lineNumber: Int, + ) { + val lineStartOffset = document.getLineStartOffset(lineNumber) + val lineEndOffset = document.getLineEndOffset(lineNumber) + val lineText = + document + .getText( + com.intellij.openapi.util + .TextRange(lineStartOffset, lineEndOffset), + ).trim() + if (lineText.isNotEmpty()) { + if (isNotEmpty()) append(" ") + append(lineText) + } + } + + private fun getFileLastUpdateTime(filePath: String): Long = + try { + val virtualFile = + com.intellij.openapi.vfs.LocalFileSystem.getInstance().findFileByPath( + if (filePath.startsWith("/")) filePath else "${project.basePath}/$filePath", + ) + virtualFile?.timeStamp ?: 0L + } catch (e: Exception) { + 0L + } + + /** + * Gets the current dropdown/completion contents if any are active. + * + * @return DropdownContents containing the available items and current selection, or null if no dropdown is active or cancelled + */ + fun getCurrentDropdownContents(): String? { + return try { + val lookupManager = LookupManager.getInstance(project) + val activeLookup = lookupManager.activeLookup ?: return null + + // activeLookup might return a component instead of Lookup, so we need to get the actual Lookup + val lookup = activeLookup as? LookupImpl ?: return null + + val future = + AppExecutorUtil.getAppExecutorService().submit { + // Poll for items outside ReadAction to avoid holding read lock while sleeping + var allItems = + ReadAction.computeCancellable, Exception> { + lookup.items.toList() + } + var attempts = 0 + val maxAttempts = 3 + val pollDelayMs = 10L + + while (allItems.isEmpty() && attempts < maxAttempts) { + Thread.sleep(pollDelayMs) + allItems = + ReadAction.computeCancellable, Exception> { + lookup.items.toList() + } + attempts++ + } + + if (allItems.isEmpty()) { + return@submit null + } + + // Now process items in a ReadAction + ReadAction.computeCancellable { + val items = mutableListOf() + allItems.take(MAX_DROPDOWN_ITEMS).forEach { item -> + try { + // IMPORTANT: We avoid calling item.renderElement(presentation) because + // it triggers the Kotlin Analysis API to resolve symbols, which can fail + // with KotlinIllegalArgumentExceptionWithAttachments if symbols aren't ready. + // Instead, we just use the basic lookupString which is always available. + items.add( + DropdownItem( + lookupString = item.lookupString, + presentationText = item.lookupString, + tailText = null, + typeText = null, + isSelected = item == lookup.currentItem, + pattern = + runCatching { + lookup.itemPattern(item) + }.getOrElse { "" }, + ), + ) + } catch (e: Throwable) { + // If processing an individual item fails, add it with minimal info + items.add( + DropdownItem( + lookupString = item.lookupString, + presentationText = item.lookupString, + tailText = null, + typeText = null, + isSelected = false, + pattern = "", + ), + ) + } + } + + items.joinToString("\n") { it.presentationText } + } + } + + try { + future.get(MAX_DROPDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: Throwable) { + future.cancel(false) + null + } + } catch (e: Throwable) { + null + } + } + + /** + * Data class representing the contents of a dropdown/completion popup. + */ + data class DropdownContents( + val items: List, + val selectedIndex: Int?, + val isCompletion: Boolean, + val isFocused: Boolean, + val bounds: java.awt.Rectangle, + val lookupStart: Int, + ) + + /** + * Data class representing a single item in the dropdown. + */ + data class DropdownItem( + val lookupString: String, + val presentationText: String, + val tailText: String?, + val typeText: String?, + val isSelected: Boolean, + val pattern: String, + ) + + private fun sortByTermComplexity(terms: List): List = + terms.sortedByDescending { term -> + val underscoreCount = term.count { it == '_' }.toDouble() + // pascal case is if the entire term is not uppercase letters and how many uppercase letters it contains + val pascalCaseCount = if (term.all { it.isUpperCase() || !it.isLetter() }) 0.0 else term.count { it.isUpperCase() }.toDouble() + + // take the terms with highest underscore or pascal case count, then the longest + // Use a composite score: primary sort by complexity, secondary by length + maxOf(underscoreCount, pascalCaseCount) * 5 + term.length + } + + /** + * Checks if the given text is a language keyword based on the file's language. + * This helps filter out common language keywords from search results. + */ + private fun isLanguageKeyword( + text: String, + psiFile: com.intellij.psi.PsiFile, + ): Boolean { + // Get the file extension to determine the language + val fileExtension = psiFile.virtualFile?.extension ?: return false + + // Map the file extension to a language + val language = OxideCodeConstants.EXTENSION_TO_LANGUAGE[fileExtension] ?: return false + + // Get the keywords for this language + val keywords = OxideCodeConstants.LANGUAGE_KEYWORDS[language] ?: return false + + // Check if the text (case-insensitive) is in the keyword list + return keywords.contains(text.lowercase()) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/GhostTextRenderer.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/GhostTextRenderer.kt new file mode 100644 index 0000000..141391c --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/GhostTextRenderer.kt @@ -0,0 +1,1345 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.codeInsight.completion.CompletionService +import com.intellij.codeInsight.daemon.impl.HighlightInfo +import com.intellij.codeInsight.daemon.impl.HighlightInfoType +import com.intellij.codeInsight.daemon.impl.HighlightVisitor +import com.intellij.codeInsight.daemon.impl.analysis.HighlightInfoHolder +import com.intellij.lang.LanguageAnnotators +import com.intellij.lang.annotation.Annotator +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorCustomElementRenderer +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.editor.highlighter.EditorHighlighter +import com.intellij.openapi.editor.highlighter.EditorHighlighterFactory +import com.intellij.openapi.editor.highlighter.HighlighterIterator +import com.intellij.openapi.editor.impl.DocumentMarkupModel +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiRecursiveElementWalkingVisitor +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.SmartPointerManager +import com.intellij.psi.impl.source.resolve.FileContextUtil +import com.intellij.psi.tree.IElementType +import com.intellij.testFramework.LightVirtualFile +import com.intellij.ui.JBColor +import com.intellij.util.concurrency.AppExecutorUtil +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.intellij.util.concurrency.annotations.RequiresReadLock +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.oxidecode.autocomplete.adjustFullContextForIde +import com.oxidecode.autocomplete.shouldRunAnnotatorsForSemanticHighlights +import com.oxidecode.settings.OxideCodeConfig +import com.oxidecode.settings.OxideCodeMetaData +import com.oxidecode.theme.OxideCodeIcons +import com.oxidecode.theme.withAlpha +import com.oxidecode.utils.* +import java.awt.* +import java.awt.font.GlyphVector +import java.util.concurrent.Future +import java.util.concurrent.TimeoutException +import com.intellij.lang.annotation.Annotation as DaemonAnnotation + +/** + * Renderer for ghost text suggestions in the editor + */ +class GhostTextRenderer( + private val editor: Editor, + private val text: String, + private val attributes: TextAttributes, + private val showHint: Boolean = false, + private val project: Project? = null, + private val fileExtension: String? = null, + private val offset: Int? = null, + private val followsNewline: Boolean = false, +) : EditorCustomElementRenderer, + Disposable { + val logger = Logger.getInstance(GhostTextRenderer::class.java) + + private companion object { + // Tuning knobs for how much surrounding context to use when computing syntax highlighting + // The larger these are, the more accurate but costlier the highlighting becomes + private const val CONTEXT_PARENT_MAX_LINES: Int = 30 // Previously 50 + private const val FALLBACK_CONTEXT_HALF_WINDOW: Int = 20 // Previously ~20 + private const val ABS_MAX_CONTEXT_WINDOW: Int = 60 // Previously 100 + private const val ABS_MAX_CONTEXT_HALF_WINDOW: Int = ABS_MAX_CONTEXT_WINDOW / 2 // 30 + + // Timeout for semantic highlighting computation to avoid blocking the UI + private const val SEMANTIC_HIGHLIGHTING_TIMEOUT_MS: Long = 200 + + // Maximum number of iterations for semantic highlighting search to avoid performance issues + private const val MAX_SEMANTIC_SEARCH_ITERATIONS: Int = 1000 + + // Cache for fonts that can display specific Unicode code points (keyed by code point) + // This avoids repeatedly searching through all system fonts for the same characters + private val fallbackFontCache = mutableMapOf() + } + + // Track how many characters from the prefix have been trimmed + private var prefixTrimCount = 0 + + /** + * Base editor font used for ghost text, with IntelliJ/OS font fallback enabled. + * + * We wrap the editor scheme font with UIUtil.getFontWithFallback so that when the primary + * font doesn't contain a glyph (common on Windows for emoji, symbols, CJK, etc.), Java2D + * can transparently use linked fallback fonts instead of rendering the missing-glyph box + * (\"tofu\"). + * + * The main editor text rendering goes through ComplementaryFontsRegistry/FontInfo which + * already applies this behaviour. Ghost text runs outside that pipeline, so we must enable + * fallback explicitly here to avoid cases where: + * + * - Ghost text for a completion shows tofu on Windows + * - The same text, once accepted into the document, renders correctly in the editor + */ + val font + get() = UIUtil.getFontWithFallback(editor.colorsScheme.getFont(EditorFontType.PLAIN)) + private val isCompletionPopupVisible = CompletionService.getCompletionService().currentCompletion != null + private val hintText: String + get() { + val action = ActionManager.getInstance().getAction(AcceptEditCompletionAction.ACTION_ID) + val shortcutText = action?.let { KeymapUtil.getFirstKeyboardShortcutText(it) } + return if (!shortcutText.isNullOrEmpty()) shortcutText else "Tab" + } + private val hintFont = Font(Font.SANS_SERIF, Font.PLAIN, font.size - 1) + private val shouldShowHint: Boolean + get() { + val config = project?.let { OxideCodeConfig.getInstance(it) } + + val metadata = OxideCodeMetaData.getInstance() + return showHint && (config?.isShowAutocompleteBadge() == true || metadata.autocompleteAcceptCount <= 3) + } + + // Cached values to avoid repeated calculations in paint() + private val cachedFontMetrics by lazy { editor.contentComponent.getFontMetrics(font) } + private val cachedHintFontMetrics by lazy { editor.contentComponent.getFontMetrics(hintFont) } + private val cachedHintWidth by lazy { + val tabText = hintText + val acceptText = " to accept" + val marginBetweenTextAndHint = 16 + val pillHorizontalPadding = 4 + val spaceBetweenTabAndAccept = 2 + val icon = OxideCodeIcons.SweepIcon + val iconGap = JBUI.scale(4) + + marginBetweenTextAndHint + + cachedHintFontMetrics.stringWidth(tabText) + pillHorizontalPadding * 2 + + spaceBetweenTabAndAccept + cachedHintFontMetrics.stringWidth(acceptText) + + iconGap + icon.iconWidth + 4 + } + + // Cache for derived fonts to avoid repeated font.deriveFont() calls + private val derivedFontCache = mutableMapOf() + + /** + * Finds a font that can display the given text. + * + * On Windows, no single pre-installed font covers all Unicode scripts. This function + * searches for a font that can display the characters in the text by: + * 1. First trying the editor's font + * 2. Then trying known Windows fonts for specific scripts (Nirmala UI for Indic, etc.) + * 3. Finally falling back to searching all available system fonts + * + * Results are cached to avoid repeated expensive font searches. + */ + private fun findFontForText( + text: String, + size: Int, + ): Font { + // Find the first non-ASCII character that needs special handling + val specialChar = + text.firstOrNull { it.requiresSpecialFontHandling() } + ?: return font.deriveFont(size.toFloat()) + + // Check cache first + val cacheKey = specialChar.code + fallbackFontCache[cacheKey]?.let { cachedFontName -> + return Font(cachedFontName, Font.PLAIN, size) + } + + // Try the editor font first + if (font.canDisplay(specialChar)) { + return font.deriveFont(size.toFloat()) + } + + // Try known Windows fonts for specific scripts + val knownFonts = + listOf( + "Nirmala UI", // Windows: Indic scripts (Devanagari, Tamil, Bengali, etc.) + "Microsoft YaHei", // Windows: Chinese + "Microsoft JhengHei", // Windows: Traditional Chinese + "Malgun Gothic", // Windows: Korean + "Yu Gothic", // Windows: Japanese + "Segoe UI Symbol", // Windows: Symbols and special characters + "Segoe UI Emoji", // Windows: Emoji + "Arial Unicode MS", // Broad Unicode coverage (if installed) + ) + + for (fontName in knownFonts) { + val testFont = Font(fontName, Font.PLAIN, size) + if (testFont.canDisplay(specialChar) && testFont.family != Font.DIALOG) { + // Verify the font actually exists (Font constructor doesn't fail for missing fonts) + if (testFont.family.equals(fontName, ignoreCase = true) || + testFont.name.equals(fontName, ignoreCase = true) + ) { + fallbackFontCache[cacheKey] = fontName + return testFont + } + } + } + + // Last resort: search all system fonts + val allFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().allFonts + for (systemFont in allFonts) { + if (systemFont.canDisplay(specialChar)) { + val fontName = systemFont.family + fallbackFontCache[cacheKey] = fontName + return Font(fontName, Font.PLAIN, size) + } + } + + // If nothing works, return the editor font (will show tofu but at least won't crash) + return font.deriveFont(size.toFloat()) + } + + @Volatile + private var highlightedSegmentsResult: List>? = null + + /** + * Update the renderer by trimming the specified number of characters from the prefix + */ + fun updateByTrimmingPrefix(charsToTrim: Int) { + prefixTrimCount += charsToTrim + // Request repaint to update the display + requestRepaint() + } + + private fun requestRepaint() { + ApplicationManager.getApplication().invokeLater { + if (!editor.isDisposed) { + editor.contentComponent.repaint() + } + } + } + + private val backgroundHighlightingTask: Future>> = + AppExecutorUtil.getAppExecutorService().submit>> { + val result = + if (project != null && + fileExtension != null && + offset != null && + editor.virtualFile != null + ) { + runCatching { + computeHighlightedSegments(text) + }.getOrElse { + getUnhighlightedSegments(text) // Return unhighlighted segments on error + } + } else { + getUnhighlightedSegments(text) + } + + // Cache and request repaint without blocking the EDT + highlightedSegmentsResult = result + requestRepaint() + + result + } + + /** + * Data class representing a text segment with its highlighting attributes + */ + private data class HighlightedSegment( + val text: String, + val attributes: TextAttributes, + ) + + /** + * Find and tune the context element from the original file for semantic resolution. + * This allows the created PsiFile to resolve references using the original file's scope. + */ + @RequiresReadLock + private fun findAndTuneContextElement( + project: Project, + document: Document, + offset: Int, + ): PsiElement? { + val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document) ?: return null + var element = psiFile.findElementAt(offset) + + // Walk up the tree to find the first non-whitespace/comment parent that contains the offset. + // This ensures we get the correct scope (e.g., class body) rather than jumping to a sibling. + while (element != null && (element is PsiWhiteSpace || element is PsiComment)) { + element = element.parent + } + + // If still null, return the file itself as context + return element ?: psiFile + } + + /** + * Find the best context range by looking for the biggest parent node that's < 50 lines + */ + @RequiresReadLock + private fun findBestContextRange( + document: Document, + currentLine: Int, + offset: Int, + ): Pair { + val maxLines = CONTEXT_PARENT_MAX_LINES + + if (project != null) { + try { + val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document) + val elementAtOffset = psiFile?.findElementAt(offset) + + if (elementAtOffset != null) { + var currentElement = elementAtOffset + var bestElement: PsiElement? = currentElement + + while (currentElement?.parent != null) { + val parentElement = currentElement.parent!! + val parentRange = parentElement.runCatching { linesRange(document) }.getOrNull() ?: break + + if (parentRange.last - parentRange.first < maxLines) { + bestElement = parentElement + currentElement = parentElement + } else { + break + } + } + + val range = bestElement?.linesRange(document) ?: IntRange(currentLine, currentLine) + return Pair(range.first, range.last) + } + } catch (e: Exception) { + logger.warn("GhostTextRenderer context range (PSI error): $e") + } + } + + // Fallback: use a smaller context around the current line + val contextLines = FALLBACK_CONTEXT_HALF_WINDOW + val startLine = maxOf(0, currentLine - contextLines) + val endLine = minOf(document.lineCount - 1, currentLine + contextLines) + + return Pair(startLine, endLine) + } + + /** + * Get unhighlighted segments as fallback when highlighting is not available + */ + private fun getUnhighlightedSegments(text: String): List> = + text.lines().map { line -> listOf(HighlightedSegment(line, attributes)) } + + /** + * Check if the cursor is at a word boundary (non-alphanumeric character) + */ + @RequiresReadLock + private fun isAtWordBoundary(): Boolean = + offset?.let { offset -> + runCatching { + val document = editor.document + if (offset >= document.textLength) { + return true // End of document is a word boundary + } + + val charAtCursor = document.charsSequence[maxOf(0, offset - 1)] + !charAtCursor.isLetterOrDigit() + }.getOrNull() + } ?: false + + /** + * Get the text attributes of the token at the cursor position + */ + @RequiresReadLock + private fun getTokenAttributesAtCursor(): TextAttributes? = + offset?.let { offset -> + runCatching { + // Use the editor's existing highlighter + val highlighter = editor.highlighter + val iterator = highlighter.createIterator(maxOf(0, offset - 1)) + + // Return the text attributes at this position + iterator.textAttributes + }.getOrNull() + } + + /** + * Search for semantic highlighting attributes from DocumentMarkupModel for a token that matches + * the given token type and text. Searches first backwards from the insertion point, then forwards. + * + * @param tokenType the token type to match + * @param tokenText the token text to match + * @param searchStartOffset the offset to start searching from (typically the insertion point) + * @return TextAttributes if found, null otherwise + */ + private fun findSemanticHighlighting( + tokenType: IElementType, + tokenText: String, + searchStartOffset: Int, + ): TextAttributes? { + if (project == null) return null + + val document = editor.document + val highlighter = editor.highlighter + + // Limit how far we search to avoid performance issues in very large files + val maxIterations = MAX_SEMANTIC_SEARCH_ITERATIONS + + // Helper function to check if a token matches and has semantic highlighting + fun checkTokenAtOffset(offset: Int): TextAttributes? { + if (offset < 0 || offset >= document.textLength) return null + + val iterator = highlighter.createIterator(offset) + if (iterator.atEnd()) return null + + // Check if token type matches + if (iterator.tokenType != tokenType) return null + + // Check if token text matches + val iteratorText = document.charsSequence.subSequence(iterator.start, iterator.end).toString() + if (iteratorText != tokenText) return null + + // Found matching token, now check DocumentMarkupModel for semantic highlighting + val markupModel = DocumentMarkupModel.forDocument(document, project, false) ?: return null + val allHighlighters = markupModel.allHighlighters + + // Look for range highlighters that overlap with this token + for (highlighter in allHighlighters) { + if (highlighter.startOffset <= iterator.start && highlighter.endOffset >= iterator.end) { + val textAttributes = highlighter.getTextAttributes(editor.colorsScheme) + if (textAttributes != null && !textAttributes.isEmpty) { + return textAttributes + } + } + } + + return null + } + + // Search backwards from the insertion point + var currentIterator: HighlighterIterator? = highlighter.createIterator(searchStartOffset) + var iterations = 0 + while (currentIterator != null && !currentIterator.atEnd() && iterations < maxIterations) { + val attrs = checkTokenAtOffset(currentIterator.start) + if (attrs != null) return attrs + + currentIterator.retreat() + if (currentIterator.atEnd()) break + iterations++ + } + + // Search forwards from the insertion point + currentIterator = highlighter.createIterator(searchStartOffset) + iterations = 0 + while (!currentIterator.atEnd() && iterations < maxIterations) { + val attrs = checkTokenAtOffset(currentIterator.start) + if (attrs != null) return attrs + + currentIterator.advance() + iterations++ + } + + return null + } + + /** + * Get semantic highlights from HighlightVisitor and Annotators + */ + @RequiresReadLock + private fun getSemanticHighlights( + psiFile: PsiFile, + startOffset: Int, + endOffset: Int, + runAnnotators: Boolean = false, + ): List { + // Skip when indexing to avoid excessive work and churn + if (DumbService.isDumb(psiFile.project)) return emptyList() + val visitorHolder = HighlightInfoHolder(psiFile) + val mergedHighlights = mutableListOf() + + // Run HighlightVisitors + try { + val visitors = HighlightVisitor.EP_HIGHLIGHT_VISITOR.getExtensions(psiFile.project).filter { it.suitableForFile(psiFile) } + var lastProcessedIndex = 0 + for (visitor in visitors) { + ProgressManager.checkCanceled() + val clonedVisitor = visitor.clone() + try { + clonedVisitor.analyze(psiFile, true, visitorHolder) { + var visited = 0 + psiFile.accept( + object : PsiRecursiveElementWalkingVisitor() { + override fun visitElement(element: PsiElement) { + ProgressManager.checkCanceled() + val range = element.textRange + if (range.endOffset < startOffset || range.startOffset > endOffset) return + if (++visited > MAX_SEMANTIC_SEARCH_ITERATIONS) { + this.stopWalking() + return + } + super.visitElement(element) + clonedVisitor.visit(element) + } + }, + ) + } + + val currentSize = visitorHolder.size() + for (i in lastProcessedIndex until currentSize) { + val info = visitorHolder.get(i) + if (info.startOffset >= startOffset && info.endOffset <= endOffset) { + mergedHighlights.add(info) + } + } + lastProcessedIndex = currentSize + } catch (e: Exception) { + if (e is ProcessCanceledException) throw e + } + } + } catch (t: Throwable) { + if (t is ProcessCanceledException) throw t + } + + // Run language Annotators and convert their annotations to HighlightInfo + if (runAnnotators) { + runCatching { + val annotators: List = LanguageAnnotators.INSTANCE.allForLanguageOrAny(psiFile.language) + + for (annotator in annotators) { + ProgressManager.checkCanceled() + try { + val annotations: List = + runCatching { + // Use reflection to call AnnotationSessionImpl.computeWithSession + val annotationSessionImplClass = tryLoadClass("com.intellij.codeInsight.daemon.impl.AnnotationSessionImpl") + val annotationHolderImplClass = tryLoadClass("com.intellij.codeInsight.daemon.impl.AnnotationHolderImpl") + + val computeWithSessionMethod = + tryGetStaticMethod( + annotationSessionImplClass, + "computeWithSession", + PsiFile::class.java, + java.lang.Boolean.TYPE, + Annotator::class.java, + java.util.function.Function::class.java, + ) + + val runAnnotatorMethod = + tryMethodWithParams( + annotationHolderImplClass, + "runAnnotatorWithContext", + PsiElement::class.java, + ) + + val assertAllAnnotationsMethod = + tryMethod( + annotationHolderImplClass, + "assertAllAnnotationsCreated", + ) + + if (computeWithSessionMethod == null || + runAnnotatorMethod == null || + assertAllAnnotationsMethod == null + ) { + return@runCatching emptyList() + } + + val function = + java.util.function.Function> { holder -> + if (annotationHolderImplClass?.isInstance(holder) != true) { + return@Function emptyList() + } + + // Walk only overlapping PSI + var visited = 0 + psiFile.accept( + object : PsiRecursiveElementWalkingVisitor() { + override fun visitElement(element: PsiElement) { + ProgressManager.checkCanceled() + val range = element.textRange + if (range.endOffset < startOffset || range.startOffset > endOffset) return + if (++visited > MAX_SEMANTIC_SEARCH_ITERATIONS) { + this.stopWalking() + return + } + tryInvokeMethod(holder, runAnnotatorMethod, element) + super.visitElement(element) + } + }, + ) + tryInvokeMethod(holder, assertAllAnnotationsMethod) + + // AnnotationHolderImpl extends SmartList, so the holder IS already a List + @Suppress("UNCHECKED_CAST") + (holder as? List) ?: emptyList() + } + + @Suppress("UNCHECKED_CAST") + ( + tryInvokeStaticMethod( + computeWithSessionMethod, + psiFile, + false, + annotator, + function, + ) as? List + ) + ?: emptyList() + }.getOrElse { emptyList() } + + for (ann in annotations) { + ProgressManager.checkCanceled() + if (ann.startOffset >= startOffset && ann.endOffset <= endOffset) { + val builder = + HighlightInfo + .newHighlightInfo(HighlightInfoType.INFORMATION) + .range(ann.startOffset, ann.endOffset) + .severity(ann.severity) + + val enforced = ann.enforcedTextAttributes + val key = ann.textAttributes + if (enforced != null) { + builder.textAttributes(enforced) + } else { + builder.textAttributes(key) + } + + mergedHighlights.add(builder.createUnconditionally()) + } + } + } catch (e: Exception) { + if (e is ProcessCanceledException) throw e + } + } + }.onFailure { if (it !is ProcessCanceledException) logger.warn("Error running LanguageAnnotators", it) } + } + + return mergedHighlights + } + + /** + * Get syntax-highlighted segments for the given text using surrounding code context + * Highlighting priority: + * 1. Semantic (from HighlightVisitors) + * 2. Semantic (searching through neighboring snippets of editor) + * 3. Syntax (from editor highlighter) + * 4. Default (fallback to default attributes) + * Edge cases: + * - First token depending on whether cursor is at word boundary + * - If token type is a comment of any kind + */ + @RequiresBackgroundThread + private fun computeHighlightedSegments(text: String): List> { + // Early return if we don't have the necessary context or if editor has no virtual file + if (project == null || fileExtension == null || offset == null || editor.virtualFile == null) { + // Fallback to single segment with default attributes + return getUnhighlightedSegments(text) + } + + try { + // Get surrounding code context from the editor + val document = editor.document + val currentLine = document.getLineNumber(offset) + + val (startLine, endLine) = + ApplicationManager.getApplication().runReadAction> { + // Find the biggest parent node that's < 50 lines + findBestContextRange(document, currentLine, offset).let { (start, end) -> + // If context is too large, limit to a smaller window around the current line + if (end - start > ABS_MAX_CONTEXT_WINDOW) { + val limitedStart = maxOf(0, currentLine - ABS_MAX_CONTEXT_HALF_WINDOW) + val limitedEnd = minOf(document.lineCount - 1, currentLine + ABS_MAX_CONTEXT_HALF_WINDOW) + Pair(limitedStart, limitedEnd) + } else { + Pair(start, end) + } + } + } + + val startOffset = document.getLineStartOffset(startLine) + val endOffset = document.getLineEndOffset(endLine) + val beforeContext = document.charsSequence.subSequence(startOffset, offset).toString() + val afterContext = document.charsSequence.subSequence(offset, endOffset).toString() + + // Create full context with ghost text inserted at caret position + val fullContext = + if (followsNewline) { + beforeContext + "\n" + text + afterContext + } else { + beforeContext + text + afterContext + } + val ghostTextStartOffset = beforeContext.length + if (followsNewline) 1 else 0 + val ghostTextEndOffset = ghostTextStartOffset + text.length + // Determine whether to run annotators for semantic highlights + val runAnnotators = shouldRunAnnotatorsForSemanticHighlights(project) + + // Choose context and offsets based on runAnnotators flag. We avoid calling + // adjustFullContextForIde when not needed since it may be expensive. + val (usedFullContext, usedGhostTextStartOffset, usedGhostTextEndOffset) = + if (runAnnotators) { + val adjusted = adjustFullContextForIde(fullContext) + val prependDelta = + if (adjusted != fullContext && adjusted.endsWith(fullContext)) { + adjusted.length - fullContext.length + } else { + 0 + } + Triple(adjusted, ghostTextStartOffset + prependDelta, ghostTextEndOffset + prependDelta) + } else { + Triple(fullContext, ghostTextStartOffset, ghostTextEndOffset) + } + + // Create a virtual file with the full context for proper syntax highlighting + val (virtualFile, psiFile) = + ApplicationManager.getApplication().runReadAction> { + // IMPORTANT, DO NOT CREATE VFILE WITH FILETYPE, FOR SOME REASON IT CHANGES EDITORHIGHLIGHTER + val vFile = LightVirtualFile("ghost_context.$fileExtension", usedFullContext) + val pFile = PsiManager.getInstance(project).findFile(vFile) + + // Set the context element from the original file for semantic resolution. + // This allows the virtual PsiFile to resolve references using the original file's scope. + if (pFile != null) { + val contextElement = findAndTuneContextElement(project, document, offset) + if (contextElement != null) { + val pointer = + SmartPointerManager + .getInstance(project) + .createSmartPsiElementPointer(contextElement) + pFile.putUserData(FileContextUtil.INJECTED_IN_ELEMENT, pointer) + } + } + + Pair(vFile, pFile) + } + + // Create an editor highlighter for the full context + val highlighter = + ApplicationManager.getApplication().runReadAction { + EditorHighlighterFactory.getInstance().createEditorHighlighter(project, virtualFile) + } + highlighter.setText(usedFullContext) + + // Get semantic highlights from HighlightVisitor if PSI file is available + val semanticHighlights = + psiFile?.let { file -> + // Compute semantic highlights using a non-blocking read action so it cancels + // immediately when a write action is requested (typing), avoiding UI freezes. + val promise = + ReadAction + .nonBlocking> { + getSemanticHighlights( + file, + usedGhostTextStartOffset, + usedGhostTextEndOffset, + runAnnotators = runAnnotators, + ) + }.submit(AppExecutorUtil.getAppExecutorService()) + + try { + // Bound the wait; if it takes too long, cancel and fall back quickly. + promise.blockingGet(SEMANTIC_HIGHLIGHTING_TIMEOUT_MS.toInt()) ?: emptyList() + } catch (_: TimeoutException) { + promise.cancel() + emptyList() + } catch (_: Throwable) { + promise.cancel() + emptyList() + } + } ?: emptyList() + + val segments = mutableListOf() + val iterator = highlighter.createIterator(usedGhostTextStartOffset) + + // Compute isAtWordBoundary once and reuse it + var (cursorTokenAttributes, isPartialFirstToken) = + ApplicationManager.getApplication().runReadAction> { + val atWordBoundary = isAtWordBoundary() + val tokenAttrs = getTokenAttributesAtCursor().takeIf { !atWordBoundary } + // isPartialFirstToken is true if we're partially through the first word (e.g., myV|ar where user typed "myV") + val isPartial = !atWordBoundary + Pair(tokenAttrs, isPartial) + } + var isFirstToken = true + + // Extract highlighting information only for the ghost text portion + while (!iterator.atEnd() && iterator.start < usedGhostTextEndOffset) { + val segmentStart = maxOf(iterator.start, usedGhostTextStartOffset) + val segmentEnd = minOf(iterator.end, usedGhostTextEndOffset) + + if (segmentStart < segmentEnd) { + val segmentText = usedFullContext.substring(segmentStart, segmentEnd) + + // If whitespace-only, add with default attributes and immediately continue to next iterator + if (segmentText.isBlank()) { + segments.add(HighlightedSegment(segmentText, attributes)) + // Do not alter first-token flags for whitespace + iterator.advance() + continue + } else { + // Get the text attributes for this token from syntax highlighting + // We fall back to this if we cannot obtain semantic highlighting + var tokenAttrsFromSyntax = iterator.textAttributes ?: attributes + val editorColorsScheme = editor.colorsScheme + val unusedColor = editorColorsScheme.getAttributes(CodeInsightColors.NOT_USED_ELEMENT_ATTRIBUTES).foregroundColor + val errorColor = editorColorsScheme.getAttributes(CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES).foregroundColor + + // Check if there's a semantic highlight for this range + val semanticHighlight = + semanticHighlights.firstOrNull { highlight -> + // Check if the highlight covers this range + val coversRange = highlight.startOffset <= segmentStart && highlight.endOffset >= segmentEnd + + if (!coversRange) return@firstOrNull false + + // Check if forcedTextAttributes is not null + if (highlight.forcedTextAttributes != null) return@firstOrNull true + + // Otherwise check if getTextAttributes has a non-null foreground color + val attrs = highlight.getTextAttributes(psiFile, editorColorsScheme) + attrs != null && attrs.foregroundColor != null + } + + // Use semantic highlight attributes if available, otherwise fall back to syntax highlighting + if (semanticHighlight != null) { + val semanticAttrs = + semanticHighlight.forcedTextAttributes + ?: semanticHighlight.getTextAttributes(psiFile, editorColorsScheme) + if (semanticAttrs != null && !semanticAttrs.isEmpty) { + tokenAttrsFromSyntax = semanticAttrs + } + } else if (( + tokenAttrsFromSyntax.isEmpty || + tokenAttrsFromSyntax.foregroundColor == editorColorsScheme.defaultForeground + ) && + !isPartialFirstToken && + segmentText.isNotBlank() + ) { + // if no syntax highlighting OR syntax highlighting is the exact same as default we try legacy semantic highlighting + val tokenType = iterator.tokenType + if (tokenType != null) { + val semanticAttrs = findSemanticHighlighting(tokenType, segmentText, offset) + // dont use if it's the same as the unused color + if (semanticAttrs != null) { + if (semanticAttrs.foregroundColor != unusedColor && semanticAttrs.foregroundColor != errorColor) { + tokenAttrsFromSyntax = semanticAttrs + } + } + } + } + + // For the first token, use cursor token color if available and cursor is not at word boundary + val baseAttributes = cursorTokenAttributes?.takeIf { isPartialFirstToken } ?: tokenAttrsFromSyntax + + // Apply ghost text styling (make it slightly transparent, preserve original font type) + val ghostAttributes = + baseAttributes.clone().apply { + // Keep original scaling for most tokens; for comments, make it more pronounced + val tokenTypeName = iterator.tokenType?.toString() + val isCommentToken = tokenTypeName?.contains("COMMENT", ignoreCase = true) == true + + val fgBase = (foregroundColor ?: editor.colorsScheme.defaultForeground) + foregroundColor = + if (isCommentToken) { + // Make comment suggestions clearly visible in light mode while keeping dark mode as-is + // Light: slight desaturation, slightly darker for contrast, higher alpha + // Dark: keep saturation, no brightness change, moderate alpha + fgBase + .withReducedSaturationPreservingLuminance(0.85f, 1.0f) + .withAdjustedBrightnessPreservingHue(1.4f, 0.7f) + .withAlpha(0.9f, 1.0f) + } else { + // Original, subtler scaling for non-comment tokens + fgBase + .withReducedSaturationPreservingLuminance(0.75f, 0.65f) + .withAlpha(0.75f, 0.65f) + } + } + + segments.add(HighlightedSegment(segmentText, ghostAttributes)) + isFirstToken = false + isPartialFirstToken = false + } + } + + iterator.advance() + } + + // Group segments by lines + + val result = + if (segments.isEmpty()) { + getUnhighlightedSegments(text) + } else { + groupSegmentsByLines(segments, text) + } + + return result + } catch (e: Exception) { + // Rethrow ProcessCanceledException to properly cancel the operation + if (e is ProcessCanceledException) throw e + // Fallback to single segment with default attributes on any error + return getUnhighlightedSegments(text) + } + } + + /** + * Group segments by lines, splitting segments that contain newlines + */ + private fun groupSegmentsByLines( + segments: List, + originalText: String, + ): List> { + val result = mutableListOf>() + result.add(mutableListOf()) // Start with first line + + for (segment in segments) { + val newlineIndex = segment.text.indexOf('\n') + + if (newlineIndex == -1) { + // No newline, add segment to current line + result.last().add(segment) + } else { + // Split on newline + val beforeNewline = segment.text.substring(0, newlineIndex) + val afterNewline = segment.text.substring(newlineIndex + 1) + + // Add part before newline to current line (if not empty) + if (beforeNewline.isNotEmpty()) { + result.last().add(HighlightedSegment(beforeNewline, segment.attributes)) + } + + // Start new line + result.add(mutableListOf()) + + // Add part after newline to new line (if not empty) + if (afterNewline.isNotEmpty()) { + result.last().add(HighlightedSegment(afterNewline, segment.attributes)) + } + } + } + + return result + } + + private fun drawTabHint( + g: Graphics, + textWidth: Int, + targetRegion: Rectangle, + inlay: Inlay<*>, + additionalYOffset: Int = 0, + ) { + if (!shouldShowHint) return + + val originalFont = g.font + g.font = hintFont + + val tabText = hintText + val acceptText = " to accept" + + val tabWidth = g.fontMetrics.stringWidth(tabText) + val tabHeight = g.fontMetrics.height - 2 + + val marginBetweenTextAndHint = 16 + val iconGap = JBUI.scale(4) + val spaceBetweenTabAndAccept = 2 + val icon = OxideCodeIcons.SweepIcon + + val baselineY = targetRegion.y + inlay.editor.ascent + additionalYOffset + val iconY = + baselineY - g.fontMetrics.ascent + (g.fontMetrics.height - icon.iconHeight) / 2 + // Start by placing the Tab pill right after the ghost text + val tabX = targetRegion.x + textWidth + marginBetweenTextAndHint + val tabY = baselineY - tabHeight + 2 + + val horizontalPadding = 4 + + g.color = attributes.foregroundColor.withAlpha(0.5f) + val g2d = g.create() as Graphics2D + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2d.fillRoundRect(tabX, tabY, tabWidth + horizontalPadding * 2, tabHeight, 8, 8) + g2d.dispose() + + g.color = attributes.foregroundColor + val acceptX = tabX + tabWidth + horizontalPadding * 2 + spaceBetweenTabAndAccept + g.drawString(acceptText, acceptX, baselineY) + + // Now paint the Sweep icon to the right of the accept text + val acceptWidth = g.fontMetrics.stringWidth(acceptText) + val iconX = acceptX + acceptWidth + iconGap + icon.paintIcon(inlay.editor.contentComponent, g, iconX, iconY) + + g.color = JBColor.WHITE + g.drawString(tabText, tabX + horizontalPadding, baselineY) + + g.font = originalFont + g.color = attributes.foregroundColor + } + + override fun calcHeightInPixels(inlay: Inlay<*>): Int { + val lineCount = text.lines().size.coerceAtLeast(1) + return (inlay.editor as EditorImpl).lineHeight * lineCount + } + + override fun calcWidthInPixels(inlay: Inlay<*>): Int { + // Get the effective text after trimming + val effectiveText = if (prefixTrimCount >= text.length) "" else text.substring(prefixTrimCount) + + // Calculate width using the same font logic as rendering + val segments = getUnhighlightedSegments(effectiveText) + val textWidth = + if (segments.isNotEmpty()) { + segments[0].sumOf { segment -> + val textSegments = splitTextOnComplexScript(segment.text) + textSegments.sumOf { (segmentText, isComplexScript) -> + // This logic must mirror the font selection behaviour in paint(): + // - For complex / unsupported runs we rely on Graphics2D.drawString() + + // OS font fallback (especially important on Windows). + // - For simple runs we use the base editor font (or its style variants). + val needsFallbackFont = isComplexScript || font.canDisplayUpTo(segmentText) != -1 + + val segmentFont = + if (!needsFallbackFont) { + val fontType = segment.attributes.fontType + if (fontType != Font.PLAIN) { + derivedFontCache.getOrPut(fontType) { font.deriveFont(fontType) } + } else { + font + } + } else { + // For width calculation of segments that will be drawn via drawString() + // we still approximate using the base font metrics. We intentionally do + // NOT use TextLayout here, because creating a GlyphVector with a fixed + // font can bypass the platform font-fallback pipeline on Windows and + // lead to tofu in the on-screen rendering. + font + } + + val fontMetrics = inlay.editor.contentComponent.getFontMetrics(segmentFont) + fontMetrics.getStringWidthWithTabs(segmentText, inlay.editor) + } + } + } else { + cachedFontMetrics.getStringWidthWithTabs(effectiveText, inlay.editor) + } + + val hintWidth = if (shouldShowHint) cachedHintWidth else 0 + return (textWidth + hintWidth).coerceAtLeast(1) + } + + override fun paint( + inlay: Inlay<*>, + g: Graphics, + targetRegion: Rectangle, + textAttributes: TextAttributes, + ) { + attributes.backgroundColor?.takeIf { it.alpha > 0 }?.let { backgroundColor -> + g.color = backgroundColor + g.fillRect(targetRegion.x, targetRegion.y, targetRegion.width, targetRegion.height) + } + + g.font = font + + // Get all line segments without blocking the EDT + val allLineSegments = + highlightedSegmentsResult + ?: if (backgroundHighlightingTask.isDone) { + runCatching { backgroundHighlightingTask.get() } + .onSuccess { highlightedSegmentsResult = it } + .getOrElse { getUnhighlightedSegments(text) } + } else { + // Compute still in progress; draw fallback now + getUnhighlightedSegments(text) + } + + var additionalYOffset = 0 + + for (i in allLineSegments.indices) { + val lineSegments = allLineSegments[i] + val y = targetRegion.y + inlay.editor.ascent + additionalYOffset + + // Paint segments for this line + var currentX = targetRegion.x + val startX = currentX // Track starting position for hint width calculation + var remainingTrim = if (i == 0) prefixTrimCount else 0 // Only trim on first line + + // If the line contains tabs, fall back to per-chunk drawing (tabs need manual expansion) + val containsTabs = lineSegments.any { it.text.indexOf('\t') >= 0 } + + if (!containsTabs) { + // Build a single AttributedString for the whole line to preserve kerning/metrics + val sb = StringBuilder() + + data class Range( + val start: Int, + val end: Int, + val color: Color, + ) + val ranges = mutableListOf() + + for (segment in lineSegments) { + // Skip fully trimmed segments + if (remainingTrim >= segment.text.length) { + remainingTrim -= segment.text.length + continue + } + + // Compute text after trimming + val textToPaint = + if (remainingTrim > 0) { + segment.text.substring(remainingTrim).also { remainingTrim = 0 } + } else { + segment.text + } + + if (textToPaint.isEmpty()) continue + + val start = sb.length + sb.append(textToPaint) + val end = sb.length + val color = segment.attributes.foregroundColor ?: attributes.foregroundColor + ranges.add(Range(start, end, color)) + } + + val full = sb.toString() + if (full.isNotEmpty()) { + // Check if complex script is present OR if the font can't display all characters. + // GlyphVector doesn't support font fallback, so we must use drawString() for + // any characters the editor font can't render (which shows as "tofu" on Windows). + val hasComplexScript = splitTextOnComplexScript(full).any { it.second } + val fontCanDisplayAll = font.canDisplayUpTo(full) == -1 + val canUseGlyphVectorOnPlatform = !SystemInfo.isWindows + val useGlyphVector = canUseGlyphVectorOnPlatform && !hasComplexScript && fontCanDisplayAll + + val g2d = g as? Graphics2D + val glyphVector: GlyphVector? = + if (useGlyphVector && g2d != null) { + val candidate = font.createGlyphVector(g2d.fontRenderContext, full) + val missingGlyphCode = font.missingGlyphCode + val containsMissingGlyph = + (0 until candidate.numGlyphs).any { glyphIndex -> + val code = candidate.getGlyphCode(glyphIndex) + code == missingGlyphCode || + candidate.getGlyphOutline(glyphIndex).bounds2D.isEmpty + } + + if (containsMissingGlyph) { + null + } else { + candidate + } + } else { + null + } + + if (glyphVector != null && g2d != null) { + // Lay out the full string once, then draw with per-range clipping to apply colors. + val fm = cachedFontMetrics + val gv = glyphVector + + // Compute character boundary x-positions directly from the GlyphVector to avoid + // inconsistencies with FontMetrics/stringWidth (kerning, fractional metrics). + val glyphCount = gv.numGlyphs + // In non-complex scripts (we already checked), glyph indices correspond to char indices + // and positions[i].x is the advance up to i. + val charCount = full.length + // Safeguard in case of any mismatch (e.g., ligatures) – fall back to min size + val maxIndex = minOf(charCount, glyphCount) + + fun posForChar(index: Int): Float { + // Clamp to available glyph range. For indexes beyond glyphCount, use last position + val idx = index.coerceIn(0, glyphCount) + val p = gv.getGlyphPosition(idx) + return p.x.toFloat() + } + + for (r in ranges) { + // Use floor for start and ceil for end to ensure we don't clip off the first pixels + val startX = if (r.start <= maxIndex) posForChar(r.start) else posForChar(maxIndex) + val endX = if (r.end <= maxIndex) posForChar(r.end) else posForChar(maxIndex) + val xStart = currentX + kotlin.math.floor(startX.toDouble()).toInt() + val xEnd = currentX + kotlin.math.ceil(endX.toDouble()).toInt() + val clipW = (xEnd - xStart).coerceAtLeast(1) + + val gg = g2d.create() as Graphics2D + gg.color = r.color + gg.clip = Rectangle(xStart, y - fm.ascent, clipW, fm.ascent + fm.descent + fm.leading) + gg.drawGlyphVector(gv, currentX.toFloat(), y.toFloat()) + gg.dispose() + } + // Advance by the full layout width from the glyph vector to match drawing + val totalW = kotlin.math.ceil(posForChar(maxIndex).toDouble()).toInt() + currentX += totalW + } else { + currentX = + paintSegmentsIndividually( + lineSegments = lineSegments, + initialTrim = if (i == 0) prefixTrimCount else 0, + g = g, + y = y, + startX = currentX, + honorTabs = false, + ) + } + } + } else { + currentX = + paintSegmentsIndividually( + lineSegments = lineSegments, + initialTrim = if (i == 0) prefixTrimCount else 0, + g = g, + y = y, + startX = currentX, + honorTabs = true, + ) + } + + // Show hint on first line only - reuse the width from drawing + if (i == 0 && showHint) { + val textWidth = currentX - startX + drawTabHint(g, textWidth, targetRegion, inlay, additionalYOffset) + } + + additionalYOffset += editor.lineHeight + } + } + + private fun paintSegmentsIndividually( + lineSegments: List, + initialTrim: Int, + g: Graphics, + y: Int, + startX: Int, + honorTabs: Boolean, + ): Int { + var currentX = startX + var remainingTrim = initialTrim + + for (segment in lineSegments) { + // Skip this segment if it's entirely within the trim range + if (remainingTrim >= segment.text.length) { + remainingTrim -= segment.text.length + continue + } + + // Determine the text to paint (skip trimmed prefix if any) + val textToPaint = + if (remainingTrim > 0) { + segment.text.substring(remainingTrim).also { remainingTrim = 0 } + } else { + segment.text + } + + if (textToPaint.isEmpty()) continue + + g.color = segment.attributes.foregroundColor ?: attributes.foregroundColor + + // Split segment text based on complex script characters (Windows only) + val textSegments = splitTextOnComplexScript(textToPaint).takeIf { it.isNotEmpty() } ?: listOf(textToPaint to false) + + for ((segmentText, isComplexScript) in textSegments) { + // Check if the font can display all characters in this segment. + val needsFallbackFont = isComplexScript || font.canDisplayUpTo(segmentText) != -1 + currentX = + drawSegmentText( + g = g, + segment = segment, + segmentText = segmentText, + needsFallbackFont = needsFallbackFont, + currentX = currentX, + y = y, + honorTabs = honorTabs, + ) + } + } + + return currentX + } + + private fun drawSegmentText( + g: Graphics, + segment: HighlightedSegment, + segmentText: String, + needsFallbackFont: Boolean, + currentX: Int, + y: Int, + honorTabs: Boolean, + ): Int { + var nextX = currentX + + if (needsFallbackFont && (!segmentText.contains('\t') || !honorTabs)) { + // Use Graphics2D.drawString() which allows the OS to perform font substitution + // for characters that the primary font can't render (fixes "tofu" symbols on Windows). + // + // IMPORTANT: On Windows, there is no single pre-installed font that covers all scripts: + // - Devanagari (ङ, etc.) requires Nirmala UI + // - Chinese (CJK) requires Microsoft YaHei UI + // - Cyrillic requires Segoe UI + // + // We explicitly find a font that can display the characters in the text. + val g2d = g as? Graphics2D + if (g2d != null) { + val fallbackFont = findFontForText(segmentText, font.size) + g2d.font = fallbackFont + val fm = g2d.fontMetrics + g2d.drawString(segmentText, nextX, y) + nextX += fm.stringWidth(segmentText) + } else { + val fallbackFont = findFontForText(segmentText, font.size) + g.font = fallbackFont + val segmentWidth = g.drawStringWithTabs(segmentText, nextX, y, editor) + nextX += segmentWidth + } + } else { + // Use normal font rendering for ASCII or text with tabs + g.font = + if (!needsFallbackFont) { + val fontType = segment.attributes.fontType + if (fontType != Font.PLAIN) { + derivedFontCache.getOrPut(fontType) { font.deriveFont(fontType) } + } else { + font + } + } else { + font + } + + val segmentWidth = g.drawStringWithTabs(segmentText, nextX, y, editor) + nextX += segmentWidth + } + + return nextX + } + + override fun dispose() { + // Cancel background highlighting task if still running + backgroundHighlightingTask.let { + if (!it.isDone) { + it.cancel(true) + } + } + + // Note: Don't dispose editor/project as they're managed elsewhere + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/JumpHintManager.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/JumpHintManager.kt new file mode 100644 index 0000000..1cf741b --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/JumpHintManager.kt @@ -0,0 +1,381 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.editor.* +import com.intellij.openapi.editor.event.VisibleAreaListener +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import com.intellij.openapi.util.Disposer +import com.intellij.ui.JBColor +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBViewport +import com.intellij.util.ui.JBUI +import com.oxidecode.services.IdeaVimIntegrationService +import com.oxidecode.theme.OxideCodeColors +import com.oxidecode.theme.withAlpha +import com.oxidecode.utils.contrastWithTheme +import java.awt.* +import javax.swing.JComponent + +/** + * Manager for jump hints that controls visibility and positioning + */ +class JumpHintManager( + private val editor: Editor, + private val project: Project, + private val targetLineNumber: Int, + private val lineStartOffset: Int, + parentDisposable: Disposable, +) : Disposable { + private var jumpPopup: JBPopup? = null + private var scrollListener: VisibleAreaListener? = null + private var currentEditor: Editor? = null + private var inlineInlay: Inlay? = null + private val wasVisibleOnCreation: Boolean = + isLineVisible(editor, lineStartOffset) + + init { + Disposer.register(parentDisposable, this) + } + + /** + * Sets up the visibility tracking and shows the hint if needed + */ + fun showIfNeeded() { + createJumpInlay() + + scrollListener = VisibleAreaListener { e -> updateVisibility(e.editor, targetLineNumber, lineStartOffset) } + FileEditorManager + .getInstance(project) + .selectedTextEditor + ?.let { + it.scrollingModel.addVisibleAreaListener(scrollListener!!) + currentEditor = it + } + + updateVisibility(editor, targetLineNumber, lineStartOffset) + } + + /** + * Creates the jump inline inlay at the end of the target line + */ + private fun createJumpInlay() { + if (inlineInlay != null) return + + val document = editor.document + val lineEndOffset = document.getLineEndOffset(targetLineNumber) + + val properties = + InlayProperties().apply { + relatesToPrecedingText(true) + disableSoftWrapping(true) + } + + // Add the inline inlay with styled renderer + val inlineRenderer = JumpInlineRenderer(editor, this) + inlineInlay = + editor.inlayModel.addInlineElement( + lineEndOffset, + properties, + inlineRenderer, + ) as Inlay + } + + /** + * Updates the visibility of the jump hint based on whether the target line is visible + */ + private fun updateVisibility( + editor: Editor, + lineNumber: Int, + lineStartOffset: Int, + ) { + val isVisible = wasVisibleOnCreation || isLineVisible(editor, lineStartOffset) + if (isVisible) { + jumpPopup?.dispose() + jumpPopup = null + } else if (jumpPopup == null) { + showJumpPopup(editor, lineNumber) + } + } + + /** + * Checks if a specific line is currently visible in the editor viewport + */ + private fun isLineVisible( + editor: Editor, + lineStartOffset: Int, + ): Boolean { + val visibleArea = editor.scrollingModel.visibleArea + val lineStartY = editor.offsetToPoint2D(lineStartOffset).y + val lineHeight = editor.lineHeight + val lineEndY = lineStartY + lineHeight + + return lineStartY <= visibleArea.y + visibleArea.height && lineEndY >= visibleArea.y + } + + /** + * Shows the jump popup at the appropriate position + */ + private fun showJumpPopup( + editor: Editor, + targetLineNumber: Int, + ) { + jumpPopup?.dispose() + + val visibleArea = editor.scrollingModel.visibleArea + val targetLineY = editor.visualLineToY(targetLineNumber) + val isTargetBelow = targetLineY > visibleArea.y + visibleArea.height + + val renderer = JumpHintRenderer(editor, isTargetBelow, this) + val component = renderer.createJumpHintComponent() + + jumpPopup = + JBPopupFactory + .getInstance() + .createComponentPopupBuilder(component, null) + .setResizable(false) + .setMovable(true) + .setRequestFocus(false) + .setTitle(null) + .setCancelOnClickOutside(true) + .setShowBorder(false) + .createPopup() + .apply { + addListener( + object : JBPopupListener { + override fun onClosed(event: LightweightWindowEvent) { + // This only hits if ESC was explicitly pressed. This ensures that user still enters normal mode in Vim. + // If the popup was closed via cursor movement, this won't get called + IdeaVimIntegrationService.getInstance(project).callVimEscape(editor) + } + }, + ) + } + + val editorComponent = editor.contentComponent + // Use safe cast - parent may not be JBViewport in notebook editors (e.g., Jupyter) + val viewport = editorComponent.parent as? JBViewport + val relativeComponent = viewport ?: editorComponent + val point = + Point( + relativeComponent.width / 2 - component.preferredSize.width / 2, + if (isTargetBelow) relativeComponent.height - 20 - component.preferredSize.height else 20, + ) + + jumpPopup?.show(RelativePoint(relativeComponent, point)) + } + + /** + * Cleans up resources when the hint is no longer needed + */ + override fun dispose() { + jumpPopup?.let { + Disposer.dispose(it) + } + jumpPopup = null + scrollListener?.let { listener -> + currentEditor?.scrollingModel?.removeVisibleAreaListener(listener) + } + scrollListener = null + currentEditor = null + inlineInlay?.let { + Disposer.dispose(it) + } + inlineInlay = null + } +} + +/** + * Inline renderer for jump hints that appear at the target line + */ +class JumpInlineRenderer( + private val editor: Editor, + parentDisposable: Disposable, +) : EditorCustomElementRenderer, + Disposable { + private val tabText: String + get() { + val action = ActionManager.getInstance().getAction(AcceptEditCompletionAction.ACTION_ID) + val shortcutText = action?.let { KeymapUtil.getFirstKeyboardShortcutText(it) } + return if (!shortcutText.isNullOrEmpty()) shortcutText else "Tab" + } + private val actionText = " to jump here" + + init { + Disposer.register(parentDisposable, this) + } + + override fun calcWidthInPixels(inlay: Inlay<*>): Int { + val font = editor.colorsScheme.getFont(com.intellij.openapi.editor.colors.EditorFontType.PLAIN) + val fontMetrics = editor.contentComponent.getFontMetrics(font) + + val tabWidth = fontMetrics.stringWidth(tabText) + val actionWidth = fontMetrics.stringWidth(actionText) + val horizontalPadding = 8 + val spacing = 4 + + return tabWidth + horizontalPadding * 2 + spacing + actionWidth + 16 // 16 for left margin + } + + override fun calcHeightInPixels(inlay: Inlay<*>): Int = editor.lineHeight + + override fun paint( + inlay: Inlay<*>, + g: Graphics, + targetRegion: Rectangle, + textAttributes: TextAttributes, + ) { + val g2d = g.create() as Graphics2D + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + val font = JBUI.Fonts.label() + val smallerFont = font.deriveFont(font.size - 2.0f) // Make text smaller + g2d.font = smallerFont + val fm = g2d.fontMetrics + + val tabWidth = fm.stringWidth(tabText) + val actionWidth = fm.stringWidth(actionText) + val tabHeight = fm.height - 2 // Increased vertical padding for Tab button (2px more) + val tabHorizontalPadding = 4 + val spacing = 2 + val py = 4 // Decreased container vertical padding (2px less) + val px = 12 + val leftMargin = px * 2 + val totalWidth = tabWidth + tabHorizontalPadding * 2 + spacing + actionWidth + val totalHeight = tabHeight + py * 2 // Increased for more spacing above + + val startX = targetRegion.x + leftMargin + val startY = targetRegion.y + (targetRegion.height - totalHeight) / 2 + + // Draw the overall background with border (increased internal padding) + val backgroundColor = + editor.colorsScheme.defaultBackground + .brighter() + .withAlpha(0.8f) + val borderColor = OxideCodeColors.foregroundColor.withAlpha(0.3f) + + g2d.color = backgroundColor + g2d.fillRoundRect(startX - px, startY, totalWidth + px * 2, totalHeight, 8, 8) + + // Draw border + g2d.color = borderColor + g2d.drawRoundRect(startX - px, startY, totalWidth + px * 2, totalHeight, 8, 8) + + val tabX = startX + val tabY = startY + py // Increased top padding above the Tab button + + // Draw the Tab button background (translucent foreground color, no border) + g2d.color = OxideCodeColors.foregroundColor.withAlpha(0.1f) // More translucent foreground color + g2d.fillRoundRect(tabX, tabY, tabWidth + tabHorizontalPadding * 2, tabHeight, 4, 4) + + // Draw the Tab text (properly centered in the button) + val isDarkMode = !JBColor.isBright() + g2d.color = if (isDarkMode) OxideCodeColors.foregroundColor.withAlpha(0.8f) else OxideCodeColors.foregroundColor + val tabTextY = tabY + tabHeight / 2 + fm.ascent / 2 - fm.descent / 2 + g2d.drawString(tabText, tabX + tabHorizontalPadding, tabTextY) + + // Draw the action text (aligned with Tab text) + g2d.color = if (isDarkMode) OxideCodeColors.foregroundColor.withAlpha(0.8f) else OxideCodeColors.foregroundColor + g2d.drawString(actionText, tabX + tabWidth + tabHorizontalPadding * 2 + spacing, tabTextY) + + // Draw a full-height cursor indicator with more spacing from the box + val cursorX = startX - px * 2 // Increased spacing between cursor and box + val cursorY = startY + val cursorHeight = totalHeight + g2d.color = Color(0x007ACC) // Blue cursor color + g2d.fillRoundRect(cursorX, cursorY, 2, cursorHeight, 2, 2) + + g2d.dispose() + } + + override fun dispose() { + // No resources to clean up for this renderer + } +} + +/** + * Renderer for jump hint UI elements + */ +class JumpHintRenderer( + private val editor: Editor, + private val isTargetBelow: Boolean, + parentDisposable: Disposable, +) : Disposable { + private val tabText: String + get() { + val action = ActionManager.getInstance().getAction(AcceptEditCompletionAction.ACTION_ID) + val shortcutText = action?.let { KeymapUtil.getFirstKeyboardShortcutText(it) } + return if (!shortcutText.isNullOrEmpty()) shortcutText else "Tab" + } + private val actionText = if (isTargetBelow) " to next move ↓" else " to next move ↑" + + init { + Disposer.register(parentDisposable, this) + } + + /** + * Creates a component with the jump hint UI + */ + fun createJumpHintComponent(): JComponent = + object : JComponent() { + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + + val g2d = g.create() as Graphics2D + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + val font = JBUI.Fonts.label() + val smallerFont = font.deriveFont(font.size - 2.0f) // Make text smaller + g2d.font = smallerFont + val fm = g2d.fontMetrics + + val tabWidth = fm.stringWidth(tabText) + val actionWidth = fm.stringWidth(actionText) + val tabHeight = fm.height - 2 + val tabHorizontalPadding = 4 + val spacing = 2 + + val totalWidth = tabWidth + tabHorizontalPadding * 2 + spacing + actionWidth + val startX = (width - totalWidth) / 2 + val tabX = startX + val tabY = (height - tabHeight) / 2 + + // Draw the Tab button background (translucent foreground color) + g2d.color = OxideCodeColors.foregroundColor.withAlpha(0.1f) + g2d.fillRoundRect(tabX, tabY, tabWidth + tabHorizontalPadding * 2, tabHeight, 4, 4) + + // Draw the Tab text (properly centered in the button) + val isDarkMode = !JBColor.isBright() + g2d.color = if (isDarkMode) OxideCodeColors.foregroundColor.withAlpha(0.8f) else OxideCodeColors.foregroundColor + val tabTextY = tabY + tabHeight / 2 + fm.ascent / 2 - fm.descent / 2 + g2d.drawString(tabText, tabX + tabHorizontalPadding, tabTextY) + + // Draw the action text (aligned with Tab text) + g2d.color = if (isDarkMode) OxideCodeColors.foregroundColor.withAlpha(0.8f) else OxideCodeColors.foregroundColor + g2d.drawString(actionText, tabX + tabWidth + tabHorizontalPadding * 2 + spacing, tabTextY) + + g2d.dispose() + } + }.apply { + background = editor.colorsScheme.defaultBackground.contrastWithTheme() + preferredSize = Dimension(160, 30) + } + + override fun dispose() { + // No resources to clean up for this renderer + } + + companion object { + /** + * Gets the preferred size for the jump hint component + */ + val PREFERRED_SIZE = Dimension(160, 30) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/KeystrokeToEditorActionMapper.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/KeystrokeToEditorActionMapper.kt new file mode 100644 index 0000000..c6caa30 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/KeystrokeToEditorActionMapper.kt @@ -0,0 +1,93 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.openapi.actionSystem.IdeActions +import java.awt.event.KeyEvent +import javax.swing.KeyStroke + +/** + * Maps KeyStroke objects to their corresponding EditorAction IDs (IdeActions.ACTION_*). + * + * This is used to dynamically intercept editor actions based on user's keymap configuration. + * When a user configures a keystroke for AcceptEditCompletionAction or RejectEditCompletionAction, + * we need to intercept the corresponding low-level EditorAction to handle autocomplete. + */ +object KeystrokeToEditorActionMapper { + /** + * Maps a keystroke to its corresponding EditorAction ID. + * + * @param keyStroke The keystroke to map + * @return The IdeActions.ACTION_* constant ID, or null if no direct mapping exists + */ + fun mapToEditorAction(keyStroke: KeyStroke): String? { + val keyCode = keyStroke.keyCode + val modifiers = keyStroke.modifiers + + // Check if specific modifiers are present (using bitwise operations) + val hasShift = (modifiers and KeyEvent.SHIFT_DOWN_MASK) != 0 || (modifiers and KeyEvent.SHIFT_MASK) != 0 + val hasCtrl = (modifiers and KeyEvent.CTRL_DOWN_MASK) != 0 || (modifiers and KeyEvent.CTRL_MASK) != 0 + val hasMeta = (modifiers and KeyEvent.META_DOWN_MASK) != 0 || (modifiers and KeyEvent.META_MASK) != 0 + val hasAlt = (modifiers and KeyEvent.ALT_DOWN_MASK) != 0 || (modifiers and KeyEvent.ALT_MASK) != 0 + + // Check for ONLY specific modifiers (no other modifiers pressed) + val hasOnlyShift = hasShift && !hasCtrl && !hasMeta && !hasAlt + val noModifiers = modifiers == 0 + + return when { + // TAB key - main accept keybinding for inline completions + keyCode == KeyEvent.VK_TAB && noModifiers -> IdeActions.ACTION_EDITOR_TAB + + // Shift+TAB - alternative accept keybinding (maps to EditorUnindentSelection) + keyCode == KeyEvent.VK_TAB && hasOnlyShift -> IdeActions.ACTION_EDITOR_UNINDENT_SELECTION + + // ENTER key - alternative accept keybinding + keyCode == KeyEvent.VK_ENTER && noModifiers -> IdeActions.ACTION_EDITOR_ENTER + + // ESCAPE key + keyCode == KeyEvent.VK_ESCAPE && noModifiers -> IdeActions.ACTION_EDITOR_ESCAPE + + // Arrow keys (no modifiers) + keyCode == KeyEvent.VK_RIGHT && noModifiers -> IdeActions.ACTION_EDITOR_MOVE_CARET_RIGHT + keyCode == KeyEvent.VK_LEFT && noModifiers -> IdeActions.ACTION_EDITOR_MOVE_CARET_LEFT + keyCode == KeyEvent.VK_UP && noModifiers -> IdeActions.ACTION_EDITOR_MOVE_CARET_UP + keyCode == KeyEvent.VK_DOWN && noModifiers -> IdeActions.ACTION_EDITOR_MOVE_CARET_DOWN + + // Shift + Right Arrow - accept next word (like IntelliJ's InsertInlineCompletionWordAction) + keyCode == KeyEvent.VK_RIGHT && hasOnlyShift -> IdeActions.ACTION_EDITOR_MOVE_CARET_RIGHT_WITH_SELECTION + + // Delete/Backspace + keyCode == KeyEvent.VK_DELETE && noModifiers -> IdeActions.ACTION_EDITOR_DELETE + keyCode == KeyEvent.VK_BACK_SPACE && noModifiers -> IdeActions.ACTION_EDITOR_BACKSPACE + + // Space + keyCode == KeyEvent.VK_SPACE && noModifiers -> IdeActions.ACTION_EDITOR_ENTER + + // Alt/Option + Arrow combinations - accept next word (only Alt, no other modifiers) + keyCode == KeyEvent.VK_RIGHT && hasAlt && !hasShift && !hasCtrl && !hasMeta -> IdeActions.ACTION_EDITOR_NEXT_WORD + keyCode == KeyEvent.VK_LEFT && hasAlt && !hasShift && !hasCtrl && !hasMeta -> IdeActions.ACTION_EDITOR_PREVIOUS_WORD + + // Home/End - End key can be used to accept line (like IntelliJ's InsertInlineCompletionLineAction) + keyCode == KeyEvent.VK_END && noModifiers -> IdeActions.ACTION_EDITOR_MOVE_LINE_END + keyCode == KeyEvent.VK_HOME && noModifiers -> IdeActions.ACTION_EDITOR_MOVE_LINE_START + + // Cmd+Right (Mac) or Ctrl+Right - accept line (only Ctrl/Meta, no other modifiers) + keyCode == KeyEvent.VK_RIGHT && (hasCtrl || hasMeta) && !hasShift && !hasAlt -> IdeActions.ACTION_EDITOR_MOVE_LINE_END + + // Note: Ctrl/Cmd+Space (CODE_COMPLETION) is not included because it's not an EditorAction + // and cannot be wrapped by EditorActionManager + + else -> null // No direct mapping for this keystroke + } + } + + /** + * Maps multiple keystrokes to their corresponding EditorAction IDs. + * Filters out keystrokes that don't have a direct mapping. + * + * @param keyStrokes List of keystrokes to map + * @return List of unique EditorAction IDs + */ + fun mapToEditorActions(keyStrokes: List): List = + keyStrokes + .mapNotNull { mapToEditorAction(it) } + .distinct() +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/LookupUICustomizer.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/LookupUICustomizer.kt new file mode 100644 index 0000000..19eef79 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/LookupUICustomizer.kt @@ -0,0 +1,118 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.codeInsight.lookup.Lookup +import com.intellij.codeInsight.lookup.LookupManager +import com.intellij.codeInsight.lookup.impl.LookupImpl +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import com.oxidecode.settings.OxideCodeMetaData +import java.awt.BorderLayout +import java.awt.Font +import java.beans.PropertyChangeListener +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.SwingConstants + +/** + * Handles customization of IntelliJ's lookup (code completion) UI to add + * a "Press enter to accept" message at the bottom of the dropdown. + */ +class LookupUICustomizer( + private val project: Project, +) : Disposable { + private var lookupCustomizations = mutableMapOf() + private var propertyChangeListener: PropertyChangeListener? = null + + /** + * Initializes the lookup listener to monitor when lookups become active + * and customize their UI accordingly. + */ + fun initialize() { + val lookupManager = LookupManager.getInstance(project) + propertyChangeListener = + PropertyChangeListener { event -> + if (event.propertyName == LookupManager.PROP_ACTIVE_LOOKUP) { + val lookup = event.newValue as? Lookup + if (lookup is LookupImpl) { + val tracker = RecentEditsTracker.getInstance(project) + tracker.clearAutocomplete(AutocompleteDisposeReason.LOOKUP_SHOWN) + tracker.scheduleAutocompleteWithPrefetch() + + // Customize the lookup UI when it becomes active + ApplicationManager.getApplication().invokeLater { + customizeLookupUI(lookup) + } + } + } + } + lookupManager.addPropertyChangeListener(propertyChangeListener!!) + } + + /** + * Customizes the given lookup's UI by adding a footer message. + * + * @param lookup The lookup to customize + */ + private fun customizeLookupUI(lookup: LookupImpl) { + if (lookupCustomizations.containsKey(lookup)) return + if (OxideCodeMetaData.getInstance().hasUsedLookupItem) return + + try { + // Get the lookup component + val lookupComponent = lookup.component + + if (lookupComponent is JPanel) { + // Create the footer message + val footerLabel = + JLabel("Press enter to accept completion", SwingConstants.CENTER).apply { + font = font.deriveFont(Font.BOLD, 12f) + border = JBUI.Borders.empty(4, 0) + foreground = JBColor.GRAY + background = lookupComponent.background + isOpaque = true + } + + // Add footer to the lookup component + lookupComponent.add(footerLabel, BorderLayout.SOUTH) + lookupComponent.revalidate() + lookupComponent.repaint() + + // Track this customization + lookupCustomizations[lookup] = footerLabel + + // Add listener to clean up when lookup is disposed + val disposable = + Disposable { + lookupCustomizations.remove(lookup) + try { + if (lookupComponent.isAncestorOf(footerLabel)) { + lookupComponent.remove(footerLabel) + lookupComponent.revalidate() + lookupComponent.repaint() + } + } catch (e: Exception) { + // Ignore exceptions during cleanup + } + } + + Disposer.register(lookup, disposable) + } + } catch (e: Exception) { + // Fail silently if UI customization doesn't work + println("Failed to customize lookup UI: ${e.message}") + } + } + + override fun dispose() { + // Property change listener will be cleaned up automatically when project is disposed + propertyChangeListener = null + + // Clear customizations map (individual cleanup is handled by Disposer.register) + lookupCustomizations.clear() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/RecentEditsTracker.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/RecentEditsTracker.kt new file mode 100644 index 0000000..709a449 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/autocomplete/edit/RecentEditsTracker.kt @@ -0,0 +1,2481 @@ +package com.oxidecode.autocomplete.edit + +import com.intellij.ide.DataManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.IdeActions.* +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.command.CommandEvent +import com.intellij.openapi.command.CommandListener +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.command.undo.UndoManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.EditorKind +import com.intellij.openapi.editor.actionSystem.EditorActionManager +import com.intellij.openapi.editor.event.CaretListener +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.event.EditorFactoryEvent +import com.intellij.openapi.editor.event.EditorFactoryListener +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.intellij.util.concurrency.annotations.RequiresBlockingContext +import com.intellij.util.concurrency.annotations.RequiresReadLockAbsence +import com.oxidecode.autocomplete.Debouncer +import com.oxidecode.settings.OxideCodeConfig +import com.oxidecode.services.* +import com.oxidecode.settings.OxideCodeMetaData +import com.oxidecode.settings.OxideCodeSettings +import com.oxidecode.utils.* +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.awt.KeyboardFocusManager +import java.awt.Point +import java.awt.Window +import java.awt.event.FocusEvent +import java.awt.event.FocusListener +import java.awt.event.WindowEvent +import java.awt.event.WindowFocusListener +import java.io.File +import java.util.* +import java.util.Queue +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import javax.swing.SwingUtilities +import kotlin.math.abs + +@RequiresBackgroundThread +@RequiresBlockingContext +@RequiresReadLockAbsence +private fun getVisibleLineRange(editor: Editor): Pair? { + var result: Pair? = null + ApplicationManager.getApplication().invokeAndWait { + val visibleArea = editor.scrollingModel.visibleArea + val startPosition = editor.xyToLogicalPosition(Point(0, visibleArea.y)) + val endPosition = editor.xyToLogicalPosition(Point(0, visibleArea.y + visibleArea.height)) + val totalLines = editor.document.lineCount + if (totalLines == 0) { + result = null + } else { + // Clamp line numbers to actual document bounds + val clampedStartLine = startPosition.line.coerceIn(0, totalLines - 1) + val clampedEndLine = endPosition.line.coerceIn(0, totalLines - 1) + result = Pair(clampedStartLine, clampedEndLine) + } + } + return result +} + +fun getVirtualFileFromEditor(editor: Editor?): VirtualFile? = + editor?.virtualFile ?: editor?.let { + FileDocumentManager.getInstance().getFile(it.document) + } + +private fun getVisibleFileChunk( + editor: Editor, + project: Project, + maxChunkSize: Int = 100, +): FileChunk? { + val (startLine, endLine) = getVisibleLineRange(editor) ?: return null + val document = editor.document + val totalLines = document.lineCount + + if (totalLines == 0) return null + + val numLines = endLine - startLine + 1 + val numLinesToExpandBy = maxChunkSize / 2 - numLines + + val actualStartLine = maxOf(0, startLine - numLinesToExpandBy) + val actualEndLine = minOf(totalLines - 1, endLine + numLinesToExpandBy) + + val startOffset = document.getLineStartOffset(actualStartLine) + val endOffset = document.getLineEndOffset(actualEndLine) + + // Validate that startOffset <= endOffset to prevent IllegalArgumentException + if (startOffset > endOffset) { + return null + } + + val visibleContent = + document.charsSequence.subSequence(startOffset, endOffset).toString() + + val filePath = getVirtualFileFromEditor(editor)?.path ?: return null + val relativePath = relativePath(project, filePath) ?: filePath + + return FileChunk( + file_path = relativePath, + start_line = actualStartLine + 1, // Convert to 1-based + end_line = actualEndLine + 1, // Convert to 1-based + content = visibleContent, + timestamp = System.currentTimeMillis(), + ) +} + +private fun invokeLaterIfGatewayModeClient( + project: Project, + block: () -> Unit, +) { + if (OxideCodeConstants.GATEWAY_MODE == OxideCodeConstants.GatewayMode.CLIENT) { + ApplicationManager.getApplication().invokeLater { + block() + } + } else { + block() + } +} + +/** + * Single-entry cache for definition chunks to avoid blocking autocomplete requests. + * The cache is keyed by line number, document line count, and a prefix of the current line. + */ +private class DefinitionChunkCache( + private val ioScope: CoroutineScope, + private val getDefinitions: (EditorState) -> List, +) { + companion object { + const val MIN_PREFIX_MATCH_LENGTH = 5 + } + + private data class CacheKey( + val lineNumber: Int, + val documentLineCount: Int, + val linePrefix: String, + val filePath: String, + ) + + private data class CacheEntry( + val key: CacheKey, + val job: Job, + val result: CompletableDeferred>, + ) + + @Volatile + private var cacheEntry: CacheEntry? = null + + /** + * Creates a cache key from the current editor state. + * Uses pre-computed currentLinePrefix to avoid accessing full documentText. + */ + private fun createCacheKey(editorState: EditorState): CacheKey = + CacheKey( + lineNumber = editorState.line, + documentLineCount = editorState.documentLineCount, + linePrefix = editorState.currentLinePrefix, + filePath = editorState.filePath, + ) + + /** + * Checks if the cache key is still valid for the given editor state. + * Returns true if the cache entry is usable. + */ + private fun isCacheKeyValid( + cachedKey: CacheKey, + currentKey: CacheKey, + ): Boolean { + // Must be same file + if (cachedKey.filePath != currentKey.filePath) return false + + // Line number must match, considering document size changes + if (cachedKey.lineNumber != currentKey.lineNumber) return false + + // Document line count shouldn't have changed drastically + if (abs(cachedKey.documentLineCount - currentKey.documentLineCount) > 5) return false + + // Check prefix match - the current prefix should start with the cached prefix + // or vice versa (for when user is typing) + val shorterPrefix = minOf(cachedKey.linePrefix.length, currentKey.linePrefix.length) + if (shorterPrefix >= MIN_PREFIX_MATCH_LENGTH) { + val cachedPrefixTruncated = cachedKey.linePrefix.take(shorterPrefix) + val currentPrefixTruncated = currentKey.linePrefix.take(shorterPrefix) + if (cachedPrefixTruncated != currentPrefixTruncated) return false + } else if (cachedKey.linePrefix.isNotEmpty() && currentKey.linePrefix.isNotEmpty()) { + // For short prefixes, they should match exactly + if (!currentKey.linePrefix.startsWith(cachedKey.linePrefix) && + !cachedKey.linePrefix.startsWith(currentKey.linePrefix) + ) { + return false + } + } + + return true + } + + /** + * Starts prefetching definition chunks for the given editor state. + * This should be called when the debouncer triggers. + */ + fun prefetch(editorState: EditorState) { + val currentKey = createCacheKey(editorState) + + // Check if current cache entry is still valid + cacheEntry?.let { entry -> + if (isCacheKeyValid(entry.key, currentKey)) { + // Cache is still valid, no need to prefetch + return + } + // Invalidate old cache + entry.job.cancel() + } + + // Start new prefetch + val deferred = CompletableDeferred>() + val job = + ioScope.launch { + try { + val result = + runCatching { + getDefinitions(editorState) + }.getOrElse { emptyList() } + deferred.complete(result) + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } catch (e: Exception) { + deferred.complete(emptyList()) + } + } + + cacheEntry = CacheEntry(currentKey, job, deferred) + } + + /** + * Gets definition chunks, using the cache if valid, otherwise fetching synchronously. + * Returns the definition chunks. + */ + suspend fun getOrFetch(editorState: EditorState): List { + val currentKey = createCacheKey(editorState) + + cacheEntry?.let { entry -> + if (isCacheKeyValid(entry.key, currentKey)) { + // Cache is valid - wait for result if still computing, or return cached result + return try { + withTimeout(2000L) { + entry.result.await() + } + } catch (e: Exception) { + // Timeout or cancellation - fall through to sync fetch + entry.job.cancel() + return fetchSync(editorState) + } + } + // Cache is invalid - cancel and fetch sync + entry.job.cancel() + return fetchSync(editorState) + } + + // No cache entry exists + return fetchSync(editorState) + } + + /** + * Synchronously fetches definition chunks (current behavior). + */ + private fun fetchSync(editorState: EditorState): List = + runCatching { + getDefinitions(editorState) + }.getOrElse { emptyList() } + + /** + * Invalidates the cache. + */ + fun invalidate() { + cacheEntry?.job?.cancel() + cacheEntry = null + } +} + +@Service(Service.Level.PROJECT) +class RecentEditsTracker( + private val project: Project, +) : Disposable { + private val logger = Logger.getInstance(RecentEditsTracker::class.java) + + @Volatile + private var isDisposed = false + + companion object { + fun getInstance(project: Project): RecentEditsTracker = project.getService(RecentEditsTracker::class.java) + + const val PAUSE_THRESHOLD = 200L + const val MAX_EDITS_TRACKED = 16 + const val MAX_HIGH_RES_EDITS_TRACKED = 16 + const val FILE_SWITCH_MOVEMENT_THRESHOLD = 8000L + const val HIGH_RES_RECENT_CHANGES_TO_SEND = 16 + const val RECENT_CHANGES_TO_SEND = 6 + const val MAX_FETCH_JOBS = 8 + const val MAX_CURSOR_POSITIONS_TRACKED = 16 + const val CHUNK_SIZE_LINES = 200 + const val CHUNK_OVERLAP_LINES = 100 + const val MAX_CHUNKS_TO_SEND = 5 + const val MAX_RETRIEVAL_CHUNK_SIZE = 200 + const val CURSOR_POSITION_LIFESPAN = 30_000L + const val CURSOR_MOVEMENT_REJECTION_THRESHOLD = 1000L + const val TRACK_CURSOR_POSITIONS_ENABLED = true + const val MAX_RECENT_CURSOR_POSITIONS = 50 + const val MAX_RECENT_USER_ACTIONS = 50 + const val MAX_CLIPBOARD_LINES = 20 + const val MAX_DIFF_HUNK_SIZE = 20000 + const val LARGE_CURSOR_MOVEMENT_THRESHOLD = 100 + } + + private data class AutocompleteRequestEntry( + val id: String = UUID.randomUUID().toString(), + var editorState: EditorState, + val requestTime: Long = System.currentTimeMillis(), + ) + + private var currentJob: Job? = null + private var consumerJob: Job? = null + private val fetchJobs = + ConcurrentHashMap>>() + private val mutex = Mutex() + private val completionChannel = + Channel>(Channel.BUFFERED) + + private val trackerJob = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.Default + trackerJob) + private val ioJob = SupervisorJob() + private val ioScope = CoroutineScope(Dispatchers.IO + ioJob) + private var currentListener: DocumentListener? = null + private var currentDocument: Document? = null + private var currentCaretListener: CaretListener? = null + private var currentFocusListener: FocusListener? = null + private var windowFocusListenerDisposable: Disposable? = null + private var currentEditorWithListeners: Editor? = null + private val editorFocusListeners = ConcurrentHashMap() + private var editorFactoryListener: EditorFactoryListener? = null + private var lastFocusedEditor: Editor? = null + private val focusChangeMutex = Mutex() + private val currentWindowFocusListener: WindowFocusListener = + object : WindowFocusListener { + override fun windowGainedFocus(e: WindowEvent?) { + // Early exit if disposed + if (isDisposed || project.isDisposed) return + } + + override fun windowLostFocus(e: WindowEvent?) { + // Early exit if disposed + if (isDisposed || project.isDisposed) return + // Update original document text when window loses focus + updateOriginalDocumentText() + } + } + + private var commandListener: CommandListener? = null + private var documentTextBeforeCommand: String? = null + private val recentEdits = EvictingQueue(MAX_EDITS_TRACKED) + private val recentEditsHighRes = EvictingQueue(MAX_HIGH_RES_EDITS_TRACKED) + private val recentCursorPositions = EvictingQueue(MAX_CURSOR_POSITIONS_TRACKED) + private val recentUserActions = EvictingQueue(MAX_RECENT_USER_ACTIONS) + private val debouncer = + Debouncer({ OxideCodeConfig.getInstance(project).getDebounceThresholdMs() }, scope, project) { processLatestEdit() } + private var lastDocumentText: String? = null + private var originalDocumentText: String = "" + + // Track diagnostics with their first-seen timestamp + // Scoped per-project (persists across file switches), with a max size limit + private data class TrackedDiagnosticKey( + val filePath: String, + val startOffset: Int, + val endOffset: Int, + val message: String, + ) + + private data class TrackedDiagnosticInfo( + val timestamp: Long, + ) + + private val trackedDiagnostics = ConcurrentHashMap() + private val MAX_TRACKED_DIAGNOSTICS = 500 + + var currentSuggestion: AutocompleteSuggestion? = null + private var suggestionQueue: Queue = LinkedList() + + // Queue for import fix suggestions with timestamps and validation data + private data class ImportFixQueueEntry( + val suggestion: AutocompleteSuggestion.PopupSuggestion, + val createdAt: Long = System.currentTimeMillis(), + // Store highlight info for re-validation before showing + val expectedText: String, + var highlightStartOffset: Int, + var highlightEndOffset: Int, + ) + + private val importFixQueue: Queue = ConcurrentLinkedQueue() + private val IMPORT_FIX_FRESHNESS_MS = 30_000L // 10 seconds + + // Track accepted import fixes to prevent showing them again + private data class AcceptedImportFix( + val content: String, + val timestamp: Long, + ) + + private val acceptedImportFixes = EvictingQueue(maxSize = 1) + + private var acceptanceDisposable: Disposable? = null + private val listenerJob = SupervisorJob() + private val listenerScope = CoroutineScope(Dispatchers.Default + listenerJob) + val isCompletionShown: Boolean + get() = currentSuggestion != null + + private var lookupUICustomizer: LookupUICustomizer? = null + private val entityUsageSearchService = EntityUsageSearchService(project) + + // Cache for definition chunks - single entry cache keyed by editor state properties + private val definitionChunkCache = + DefinitionChunkCache( + ioScope = ioScope, + getDefinitions = { editorState -> entityUsageSearchService.getDefinitionsBeforeCursor(editorState) }, + ) + + private var lastAcceptedTime: Long = System.currentTimeMillis() + + // Track retrieval counts for metrics + private var lastNumDefinitionsRetrieved: Int = 0 + private var lastNumUsagesRetrieved: Int = 0 + + /** + * Schedules the debouncer and triggers definition chunk prefetch if caching is enabled. + * This should be called instead of debouncer.schedule() directly. + */ + fun scheduleAutocompleteWithPrefetch() { + debouncer.schedule() + + getCurrentEditorState()?.let { editorState -> + definitionChunkCache.prefetch(editorState) + } + } + + /** + * Helper function to get FileEditorManagerImpl instance via reflection. + * Returns null if the class is not found or the instance is not of the correct type. + */ + private fun getFileEditorManagerImpl(project: Project): Any? = + try { + val fileEditorManager = FileEditorManager.getInstance(project) + + // Check if it's actually an instance of FileEditorManagerImpl + val implClass = Class.forName("com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl") + + if (implClass.isInstance(fileEditorManager)) { + fileEditorManager + } else { + null + } + } catch (e: ClassNotFoundException) { + logger.warn("FileEditorManagerImpl class not found", e) + null + } + + /** + * Helper function to get all splitters via reflection. + * Returns null if the method is not found or an error occurs. + */ + private fun getAllSplitters(fileEditorManagerImpl: Any): List? = + try { + val method = fileEditorManagerImpl.javaClass.getMethod("getAllSplitters") + val result = method.invoke(fileEditorManagerImpl) + + // Handle both List and Array return types (may vary by version) + when (result) { + is List<*> -> result.filterNotNull() + is Array<*> -> result.filterNotNull().toList() + else -> null + } + } catch (e: NoSuchMethodException) { + logger.warn("getAllSplitters method not found", e) + null + } catch (e: Exception) { + logger.warn("Error invoking getAllSplitters", e) + null + } + + /** + * Helper function to access currentWindow property via reflection. + * Returns null if the property/field is not found or an error occurs. + */ + private fun getCurrentWindow(splitters: Any): Any? = + try { + // Try as property first (Kotlin) + val propertyMethod = splitters.javaClass.getMethod("getCurrentWindow") + propertyMethod.invoke(splitters) + } catch (e: NoSuchMethodException) { + try { + // Try as field (Java) + val field = splitters.javaClass.getDeclaredField("currentWindow") + field.isAccessible = true + field.get(splitters) + } catch (e2: Exception) { + logger.warn("Error accessing currentWindow", e2) + null + } + } catch (e: Exception) { + logger.warn("Error accessing currentWindow", e) + null + } + + /** + * Helper function to access selectedComposite property via reflection. + * Returns null if the property/method is not found or an error occurs. + */ + private fun getSelectedComposite(editorWindow: Any): Any? = + try { + // Try as property/getter method + val method = editorWindow.javaClass.getMethod("getSelectedComposite") + method.invoke(editorWindow) + } catch (e: NoSuchMethodException) { + try { + // Try alternative getter name + val altMethod = editorWindow.javaClass.getMethod("getSelectedEditor") + altMethod.invoke(editorWindow) + } catch (e2: Exception) { + logger.warn("Error accessing selectedComposite", e2) + null + } + } catch (e: Exception) { + logger.warn("Error accessing selectedComposite", e) + null + } + + /** + * Helper function to access selectedEditor property via reflection. + * Returns null if the property/method is not found or an error occurs. + */ + private fun getSelectedEditor(composite: Any): FileEditor? = + try { + val method = composite.javaClass.getMethod("getSelectedEditor") + method.invoke(composite) as? FileEditor + } catch (e: Exception) { + logger.warn("Error accessing selectedEditor", e) + null + } + + /** + * Gets the currently focused editor using a multi-level fallback strategy: + * 1. Primary: Use last focused editor from our tracking + * 2. Secondary: Use reflection to find editor in focused window + * 3. Tertiary: Use public API FileEditorManager.selectedTextEditor + */ + private fun getCurrentEditor(): Editor? { + // Primary: Use last focused editor from our tracking + lastFocusedEditor?.let { editor -> + // Validate editor is still valid + if (!editor.isDisposed && + editor.project == project && + editor.editorKind == EditorKind.MAIN_EDITOR + ) { + return editor + } + } + + // Fallback: Use reflection-based logic + val fileEditorManager = FileEditorManager.getInstance(project) + + // Get the currently focused window using IntelliJ's focus management API + val focusedWindow = + KeyboardFocusManager + .getCurrentKeyboardFocusManager() + .activeWindow + ?: return fileEditorManager.selectedTextEditor + + // Try reflection approach + try { + val fileEditorManagerImpl = + getFileEditorManagerImpl(project) + ?: return fileEditorManager.selectedTextEditor + + val allSplitters = + getAllSplitters(fileEditorManagerImpl) + ?: return fileEditorManager.selectedTextEditor + + for (splitters in allSplitters) { + // Check if this splitter belongs to the focused window + if (SwingUtilities.isDescendingFrom(splitters as? java.awt.Component, focusedWindow)) { + val currentWindow = getCurrentWindow(splitters) ?: continue + val selectedComposite = getSelectedComposite(currentWindow) ?: continue + val selectedEditor = getSelectedEditor(selectedComposite) ?: continue + + return (selectedEditor as? TextEditor)?.editor + } + } + } catch (e: Exception) { + logger.warn("Error in reflection-based getCurrentEditor", e) + } + + // Final fallback: return the selected editor from the main window + // Filter the fallback result as well + return fileEditorManager.selectedTextEditor?.takeIf { + it.editorKind == EditorKind.MAIN_EDITOR + } + } + + private fun getClipboardEntry() = + ClipboardTrackingService.getInstance(project).getCurrentClipboardEntry()?.takeIf { + it.timestamp > + lastAcceptedTime && + it.getDuration() < 1000 * 30 + } + + private fun setupEditorFactoryListener() { + editorFactoryListener = + object : EditorFactoryListener { + override fun editorCreated(event: EditorFactoryEvent) { + val editor = event.editor + + // Only track editors for this project + if (editor.project != project) return + + // Only track main code editors, not consoles/diffs/previews + if (editor.editorKind != EditorKind.MAIN_EDITOR) return + + // Ensure editor has a valid file associated with it + if (getVirtualFileFromEditor(editor) == null) return + + // Add focus listener to this editor + val focusListener = + object : FocusListener { + override fun focusGained(e: FocusEvent?) { + handleEditorFocusGained(editor) + } + + override fun focusLost(e: FocusEvent?) { + handleEditorFocusLost(editor) + } + } + + editor.contentComponent.addFocusListener(focusListener) + editorFocusListeners[editor] = focusListener + + logger.debug("Added focus listener to editor: ${editor.virtualFile?.path}") + } + + override fun editorReleased(event: EditorFactoryEvent) { + val editor = event.editor + + // Clean up focus listener + editorFocusListeners.remove(editor)?.let { listener -> + try { + editor.contentComponent.removeFocusListener(listener) + } catch (e: Exception) { + logger.warn("Error removing focus listener", e) + } + } + + // Clear if this was the last focused editor + if (lastFocusedEditor == editor) { + lastFocusedEditor = null + } + + if (currentEditorWithListeners === editor) { + detachListenersFromCurrentEditor() + } + } + } + + EditorFactory.getInstance().addEditorFactoryListener( + editorFactoryListener!!, + this, // disposable + ) + } + + private fun handleEditorFocusGained(editor: Editor) { + scope.launch { + focusChangeMutex.withLock { + // Prevent duplicate processing + if (lastFocusedEditor == editor) return@withLock + + val oldEditor = lastFocusedEditor + lastFocusedEditor = editor + + logger.info("Editor focus gained: ${editor.virtualFile?.path}") + + // Process focus change on EDT + ApplicationManager.getApplication().invokeLater { + onEditorFocusChanged(editor, oldEditor) + } + } + } + } + + private fun handleEditorFocusLost(editor: Editor) { + // Optional: track focus loss for metrics + logger.debug("Editor focus lost: ${getVirtualFileFromEditor(editor)?.path}") + } + + private fun onEditorFocusChanged( + newEditor: Editor, + oldEditor: Editor?, + ) { + // This is the main handler that replaces selectionChanged logic + + // Attach listeners to new editor + attachListenerToEditor(newEditor) + + // 3. Update original document text + updateOriginalDocumentText() + + // 4. Clear current autocomplete + acceptanceDisposable?.dispose() + acceptanceDisposable = null + clearAutocomplete(AutocompleteDisposeReason.EDITOR_FOCUS_CHANGED) + + // 5. Track cursor position + trackCursorPosition() + + // 6. Trigger autocomplete if recent edit + val lastEditTime = recentEdits.lastOrNull()?.timestamp ?: 0 + val currentTime = System.currentTimeMillis() + val isRecentFileSwitch = currentTime - lastEditTime < FILE_SWITCH_MOVEMENT_THRESHOLD + val isTutorialFile = getVirtualFileFromEditor(newEditor)?.name?.endsWith("tutorial.py") == true + if (isRecentFileSwitch || isTutorialFile) { + scheduleAutocompleteWithPrefetch() + } + } + + private fun attachListenerToEditor(editor: Editor) { + // If we're already attached to this editor, don't re-attach + if (editor === currentEditorWithListeners) { + return + } + + // Remove listeners from the previous editor + if (currentEditorWithListeners != null) { + detachListenersFromCurrentEditor() + } + + currentEditorWithListeners = editor + lastDocumentText = editor.document.text + originalDocumentText = editor.document.text + + val document = editor.document + currentDocument = document + + currentFocusListener = + object : FocusListener { + override fun focusGained(e: FocusEvent) { + // Re-attach listeners when this editor gains focus + // This handles switching between detached windows + attachListenerToEditor(editor) + } + + override fun focusLost(e: FocusEvent) { + clearAutocomplete(AutocompleteDisposeReason.EDITOR_LOST_FOCUS) + } + } + + editor.contentComponent.addFocusListener(currentFocusListener) + + // Add window focus listener to the top-level window + editor.contentComponent.topLevelAncestor?.let { window -> + if (window is Window) { + window.addWindowFocusListener(currentWindowFocusListener) + windowFocusListenerDisposable?.let { Disposer.dispose(it) } + windowFocusListenerDisposable = + Disposable { + window.removeWindowFocusListener(currentWindowFocusListener) + }.also { + Disposer.register(this@RecentEditsTracker, it) + } + } + } + + currentListener = + DocumentChangeListenerAdapter { event -> + if (project.isDisposed) return@DocumentChangeListenerAdapter + + // Clear green highlights + acceptanceDisposable?.dispose() + acceptanceDisposable = null + + // If suggestion is still valid, update it and then exit + currentSuggestion?.update(editor)?.let { offset -> + // Don't adjust the queue here - it will be adjusted when the suggestion is accepted + // Adjusting here causes double-adjustment: once during typing, once during acceptance + suggestionQueue.forEach { it.adjustOffsets(offset) } + return@DocumentChangeListenerAdapter + } + + // Otherwise clear current autocomplete and fire autocomplete + clearAutocomplete(AutocompleteDisposeReason.CLEARING_PREVIOUS_AUTOCOMPLETE) + + val editorState = getCurrentEditorState() ?: return@DocumentChangeListenerAdapter + val newText = editorState.documentText + val relativePath = relativePath(project, editorState.filePath) ?: editorState.filePath + + // No changes made, don't fire anything + if (lastDocumentText == newText) return@DocumentChangeListenerAdapter + + // Detect user action type based on document change + val actionType = + detectDocumentChangeActionType( + event = event, + ) + if (actionType != null) { + // Calculate the final cursor position based on the document event + val finalOffset = + when (actionType) { + UserActionType.INSERT_CHAR, UserActionType.INSERT_SELECTION -> { + event.offset + event.newLength + } + + UserActionType.DELETE_CHAR, UserActionType.DELETE_SELECTION -> { + event.offset + } + + else -> editorState.cursorOffset + } + + // Convert offset to line number + val document = editor.document + val finalLine = document.getLineNumber(finalOffset) + 1 // Convert to 1-based + + trackUserAction(actionType, finalLine, finalOffset, relativePath) + } + + listenerScope.launch { + val currentEdit = + EditRecord( + originalText = lastDocumentText ?: "", + newText = newText, + filePath = relativePath, + offset = event.offset, + ) + val diff = currentEdit.diff + val (addedLines, deletedLines) = countAddedAndDeletedLines(diff) + + if (addedLines > 3 || deletedLines > 3 || isFileTooLarge(newText, project)) { + withContext(Dispatchers.EDT) { lastDocumentText = newText } + return@launch + } + + ApplicationManager.getApplication().invokeLater { + val editRecord = + EditRecord( + originalText = lastDocumentText ?: "", + newText = newText, + filePath = relativePath, + offset = event.offset, + ) + if (editRecord.isTooLarge() || editRecord.isNoOpDiff()) return@invokeLater + recentEditsHighRes.add(editRecord) + } + + ApplicationManager.getApplication().invokeLater { + val previousEdit = recentEdits.lastOrNull() + val shouldCombine = shouldCombineWithPreviousEdit(previousEdit, currentEdit) + if (shouldCombine && previousEdit != null) { + val combinedEdit = + EditRecord( + originalText = previousEdit.originalText, + newText = newText, + filePath = relativePath, + offset = event.offset, + ) + if (combinedEdit.isTooLarge() || combinedEdit.isNoOpDiff()) return@invokeLater + recentEdits.replaceLast(combinedEdit) + } else { + val editRecord = + EditRecord( + originalText = lastDocumentText ?: "", + newText = newText, + filePath = relativePath, + offset = event.offset, + ) + if (editRecord.isTooLarge() || editRecord.isNoOpDiff()) return@invokeLater + recentEdits.add(editRecord) + } + + lastDocumentText = newText + + // Don't schedule autocomplete if the last change was made by the agent + val lastEditTime = recentEdits.lastOrNull()?.timestamp ?: 0 + scheduleAutocompleteWithPrefetch() + } + } + }.also { + editor.document.apply { + addDocumentListener(it) + currentDocument = this + } + } + + var lastDocumentContents = editor.document.text + var lastCursorOffset = ApplicationManager.getApplication().runReadAction { editor.caretModel.offset } + var lastCursorLine = editor.caretModel.logicalPosition.line + currentCaretListener = + CaretPositionChangedAdapter { + val documentChanged = editor.document.text != lastDocumentContents + lastDocumentContents = editor.document.text + if (documentChanged) { // If document changed, it will be handled by the document listener + return@CaretPositionChangedAdapter + } + if (lastCursorOffset == editor.caretModel.offset) { + return@CaretPositionChangedAdapter + } + + val currentCursorLine = editor.caretModel.logicalPosition.line + val lineMovement = abs(currentCursorLine - lastCursorLine) + + // Update original document text when user moves more than 100 lines + if (lineMovement > LARGE_CURSOR_MOVEMENT_THRESHOLD) { + updateOriginalDocumentText() + } + + lastCursorOffset = editor.caretModel.offset + lastCursorLine = currentCursorLine + + acceptanceDisposable?.dispose() + acceptanceDisposable = null + + val cursorPosition = + ApplicationManager.getApplication().runReadAction { + editor.caretModel.offset + } + val shouldPreserveGhostText = + currentSuggestion?.let { suggestion -> + when (suggestion) { + is AutocompleteSuggestion.GhostTextSuggestion -> { + // Calculate the suggestion position + val suggestionLine = editor.document.getLineNumber(suggestion.startOffset) + val currentLine = editor.caretModel.logicalPosition.line + + // Track initial cursor position when ghost text was first shown + val initialLine = suggestion.initialCursorLine + + if (initialLine == -1) { + // Use original behavior (time-based threshold) + !suggestion.isAtCaret && + suggestion.shownTime > 0 && + (System.currentTimeMillis() - suggestion.shownTime) < CURSOR_MOVEMENT_REJECTION_THRESHOLD + } else { + // Direction-based rejection: + // Reject if moving "past" the suggestion OR moving "away" from it + // Only preserve if cursor stays between initial position and suggestion + val startedBelow = initialLine > suggestionLine + if (startedBelow) { + // Started below: preserve if between suggestion and initial (inclusive) + currentLine in suggestionLine..initialLine + } else { + // Started above or on: preserve if between initial and suggestion (inclusive) + currentLine in initialLine..suggestionLine + } + } + } + + is AutocompleteSuggestion.MultipleGhostTextSuggestion -> { + // Calculate the suggestion line range (from first to last suggestion) + val suggestionLines = + suggestion.ghostTextSuggestions.map { + editor.document.getLineNumber(it.startOffset) + } + val minSuggestionLine = suggestionLines.minOrNull() ?: -1 + val maxSuggestionLine = suggestionLines.maxOrNull() ?: -1 + val currentLine = editor.caretModel.logicalPosition.line + + // Track initial cursor position when ghost text was first shown + val initialLine = suggestion.initialCursorLine + + if (initialLine == -1 || minSuggestionLine == -1) { + // Use original behavior (time-based threshold) + !( + suggestion.ghostTextSuggestions.any { + it.startOffset <= cursorPosition + } && + suggestion.ghostTextSuggestions.any { + it.endOffset >= cursorPosition + } + ) && + suggestion.shownTime > 0 && + (System.currentTimeMillis() - suggestion.shownTime) < CURSOR_MOVEMENT_REJECTION_THRESHOLD + } else { + // Direction-based rejection for multiple ghost text: + // Reject if moving "past" the suggestion range OR moving "away" from it + // Only preserve if cursor stays between initial position and suggestion range + val startedBelow = initialLine > maxSuggestionLine + if (startedBelow) { + // Started below: preserve if between suggestion range and initial (inclusive) + currentLine in minSuggestionLine..initialLine + } else { + // Started above or on: preserve if between initial and suggestion range (inclusive) + currentLine in initialLine..maxSuggestionLine + } + } + } + + is AutocompleteSuggestion.PopupSuggestion -> { + // Calculate the first changed line from the suggestion's startOffset + val firstChangedLine = editor.document.getLineNumber(suggestion.startOffset) + val currentLine = editor.caretModel.logicalPosition.line + + // Track initial cursor position when popup was first shown + val initialLine = suggestion.initialCursorLine + + if (initialLine == -1) { + // Use original behavior + suggestion.shownTime > 0 && + (System.currentTimeMillis() - suggestion.shownTime) < CURSOR_MOVEMENT_REJECTION_THRESHOLD + } + + // Check if cursor has crossed the suggestion (moved from one side to the other) + // When on the line (equal), it hasn't crossed + val initialSide = initialLine.compareTo(firstChangedLine) // -1 (above), 0 (on), 1 (below) + val currentSide = currentLine.compareTo(firstChangedLine) + // Crossed if went from negative to positive or positive to negative (skipping 0) + val hasCrossed = (initialSide < 0 && currentSide > 0) || (initialSide > 0 && currentSide < 0) + + // Calculate absolute distance from initial position + val initialDistance = abs(initialLine - firstChangedLine) + val currentDistance = abs(currentLine - firstChangedLine) + + // Preserve if current distance hasn't increased AND cursor hasn't crossed the suggestion + !hasCrossed && currentDistance <= initialDistance + } + + is AutocompleteSuggestion.JumpToEditSuggestion -> { + suggestion.shownTime > 0 && + (System.currentTimeMillis() - suggestion.shownTime) < CURSOR_MOVEMENT_REJECTION_THRESHOLD + } + + else -> false + } + } ?: false + + if (!shouldPreserveGhostText) { + clearAutocomplete(AutocompleteDisposeReason.CARET_POSITION_CHANGED) + } + + trackCursorPosition() + + val lastEditTime = recentEdits.lastOrNull()?.timestamp ?: 0 + val currentTime = System.currentTimeMillis() + val cursorMovementThreshold = 60000L + val cursorTrackingOrTutorialActive = + currentTime - lastEditTime < cursorMovementThreshold || + getVirtualFileFromEditor(editor)?.name?.endsWith("tutorial.py") == true + if (cursorTrackingOrTutorialActive) { + scheduleAutocompleteWithPrefetch() + } + }.also { + editor.caretModel.addCaretListener(it) + } + + logger.debug("Attached listeners to editor: ${getVirtualFileFromEditor(editor)?.path}") + } + + private fun detachListenersFromCurrentEditor() { + val editor = currentEditorWithListeners ?: return + + currentListener?.let { listener -> + currentDocument?.runCatching { removeDocumentListener(listener) } + } + currentListener = null + currentDocument = null + + currentCaretListener?.let { listener -> + editor.caretModel.runCatching { removeCaretListener(listener) } + } + currentCaretListener = null + + currentFocusListener?.let { listener -> + editor.contentComponent.runCatching { removeFocusListener(listener) } + } + currentFocusListener = null + + windowFocusListenerDisposable?.let { Disposer.dispose(it) } + windowFocusListenerDisposable = null + + currentEditorWithListeners = null + } + + private fun cleanupFocusTracking() { + // Editor factory listener will be automatically disposed via disposable parent + // No need to manually remove it + editorFactoryListener = null + + // Clean up all focus listeners + editorFocusListeners.forEach { (editor, focusListener) -> + try { + editor.contentComponent.removeFocusListener(focusListener) + } catch (e: Exception) { + logger.warn("Error removing focus listener", e) + } + + if (currentEditorWithListeners === editor) { + detachListenersFromCurrentEditor() + } + } + editorFocusListeners.clear() + + lastFocusedEditor = null + } + + init { + OxideCodeSettings.getInstance().runNowAndOnSettingsChange(project, this) { + if (nextEditPredictionFlagOn && + editorFactoryListener == null && + OxideCodeConstants.GATEWAY_MODE != OxideCodeConstants.GatewayMode.HOST + ) { + // NEW: Setup editor factory listener + setupEditorFactoryListener() + + // Ensure application-level editor actions router is initialized once + EditorActionsRouterService.getInstance() + + // Initialize for currently open editors + // Get all already-open editors for this project + val allEditors = + EditorFactory + .getInstance() + .allEditors + .filter { it.project == project } + + // Add focus listeners to all already-open editors + // This is needed because EditorFactoryListener only catches NEW editors + // created after it's registered, so we need to manually handle editors + // that were already open when the IDE restarted + allEditors.forEach { editor -> + // Filter out non-code editors (consoles, diffs, previews) + if (editor.editorKind != EditorKind.MAIN_EDITOR) return@forEach + + // Ensure editor has a valid file + if (getVirtualFileFromEditor(editor) == null) return@forEach + + val focusListener = + object : FocusListener { + override fun focusGained(e: FocusEvent?) { + handleEditorFocusGained(editor) + } + + override fun focusLost(e: FocusEvent?) { + handleEditorFocusLost(editor) + } + } + + editor.contentComponent.addFocusListener(focusListener) + editorFocusListeners[editor] = focusListener + + logger.debug("Added focus listener to already-open editor: ${getVirtualFileFromEditor(editor)?.path}") + } + + // Attach document/caret listeners to the currently focused editor + getCurrentEditor()?.let { editor -> + attachListenerToEditor(editor) + lastFocusedEditor = editor + } + + // Initialize lookup UI customizer + lookupUICustomizer = LookupUICustomizer(project) + lookupUICustomizer?.initialize() + + // Setup command listener for undo/redo detection + setupCommandListener() + + launchAutocompleteConsumerWorker() + } else if (!nextEditPredictionFlagOn && editorFactoryListener != null) { + // Cleanup + cleanupFocusTracking() + consumerJob?.cancel() + consumerJob = null + } + + if (OxideCodeConstants.GATEWAY_MODE == OxideCodeConstants.GatewayMode.HOST) { + EditorActionsRouterService.getInstance() + cleanupFocusTracking() + consumerJob?.cancel() + consumerJob = null + } + } + } + + /** + * Helper function to get the appropriate ghost text suggestion for word acceptance. + * Returns the ghost text at the cursor position for multi-ghost text, or the single ghost text. + */ + private fun getGhostTextForWordAcceptance(): AutocompleteSuggestion.GhostTextSuggestion? { + val caretOffset = getCurrentEditor()?.caretModel?.offset ?: return null + + return when (val suggestion = currentSuggestion) { + is AutocompleteSuggestion.GhostTextSuggestion -> { + suggestion + } + + is AutocompleteSuggestion.MultipleGhostTextSuggestion -> { + // Find the first ghost text that starts at the current cursor position + suggestion.ghostTextSuggestions.find { it.startOffset == caretOffset } + } + + else -> null + } + } + + private fun setupCommandListener() { + commandListener = + object : CommandListener { + override fun commandStarted(event: CommandEvent) { + val commandName = event.commandName ?: "" + + // Capture document text before undo/redo commands + if (commandName.contains("undo", ignoreCase = true) || + commandName.contains( + "redo", + ignoreCase = true, + ) + ) { + documentTextBeforeCommand = getCurrentEditorState()?.documentText + } + } + + override fun commandFinished(event: CommandEvent) { + val commandName = event.commandName ?: "" + + val editorState = getCurrentEditorState() ?: return + val relativePath = relativePath(project, editorState.filePath) ?: editorState.filePath + + when { + commandName.contains("undo", ignoreCase = true) -> { + // Only track if document text actually changed + if (documentTextBeforeCommand != null && documentTextBeforeCommand != editorState.documentText) { + trackUserAction( + UserActionType.UNDO, + editorState.line, + editorState.cursorOffset, + relativePath, + ) + } + documentTextBeforeCommand = null + } + + commandName.contains("redo", ignoreCase = true) -> { + // Only track if document text actually changed + if (documentTextBeforeCommand != null && documentTextBeforeCommand != editorState.documentText) { + trackUserAction( + UserActionType.REDO, + editorState.line, + editorState.cursorOffset, + relativePath, + ) + } + documentTextBeforeCommand = null + } + } + } + } + + project.messageBus.connect(this).subscribe(CommandListener.TOPIC, commandListener!!) + } + + fun acceptSuggestion() { + val editor = getCurrentEditor() ?: return + + // Check if project is being disposed + if (project.isDisposed) { + logger.warn("Skipping suggestion acceptance - project is disposed") + return + } + + currentSuggestion?.let { + lastAcceptedTime = System.currentTimeMillis() + val startOffset = it.startOffset + + AutocompleteRejectionCache + .getInstance(project) + .tryAddingRejectionToCache(it, AutocompleteDisposeReason.ACCEPTED) + + // Capture document reference and length before import fix is applied + // This allows us to calculate the adjustment offset from the import statement insertion + val document = editor.document + var docLengthBeforeImportFix = 0 + + invokeLaterIfGatewayModeClient(project) { + // Double-check disposal state before performing write action + if (project.isDisposed) { + logger.warn("Skipping write action - project disposed during invokeLater") + return@invokeLaterIfGatewayModeClient + } + + WriteCommandAction.runWriteCommandAction(project) { + // Capture document length before accepting import fix + // This must be inside WriteCommandAction to ensure we get the length + // at the right moment (after any gateway mode invokeLater has resolved) + if (it.isImportFix) { + docLengthBeforeImportFix = document.textLength + } + + acceptanceDisposable?.dispose() + acceptanceDisposable = + it.accept(editor).also { disposable -> + if (it is AutocompleteSuggestion.GhostTextSuggestion || + it is AutocompleteSuggestion.PopupSuggestion + ) { + val metadata = OxideCodeMetaData.getInstance() + metadata.autocompleteAcceptCount++ + } + } +// if (suggestionQueue.isEmpty()) { +// FileDocumentManager.getInstance().saveDocument(editor.document) +// } + + // Notify import detector about the accepted code insertion + AutocompleteImportDetector.getInstance(project).onCodeInserted( + editor = editor, + insertionOffset = it.startOffset, + insertedText = it.content, + ) + } + } + + if (it is AutocompleteSuggestion.JumpToEditSuggestion) { + showAutocomplete(it.originalCompletion, isShowingPostJumpSuggestion = true) + } else { + // Check if this was an import fix suggestion + val wasImportFix = it.isImportFix + + // If it's an import fix, track it as accepted with current timestamp + if (wasImportFix) { + acceptedImportFixes.add(AcceptedImportFix(it.content, System.currentTimeMillis())) + } + + currentSuggestion?.dispose() + currentSuggestion = null + + // If we're in the middle of a multi-part next edit suggestion (suggestionQueue is not empty), + // don't interrupt with import fixes - show the next part of the suggestion instead. + // Only prioritize import fixes when there are no more parts of a multi-part suggestion. + if (suggestionQueue.isNotEmpty()) { + // Continue showing remaining parts of the multi-part next edit suggestion + ApplicationManager.getApplication().invokeLater { + // Calculate adjustment offset to shift queued suggestions after this one was accepted. + // + // For import fixes: We CANNOT use suggestion.getAdjustmentOffset() because that only + // measures the difference between the suggestion content and the range it replaces. + // We diff the document length before/after the import intention action completes + val adjustmentOffset = + if (wasImportFix) { + document.textLength - docLengthBeforeImportFix + } else { + it.getAdjustmentOffset() + } + + val isAboveCursor = suggestionQueue.firstOrNull()?.takeIf { it.start_index >= startOffset } != null + if (adjustmentOffset != 0 && isAboveCursor) { + suggestionQueue.forEach { + it.adjustOffsets(adjustmentOffset) + } + } + + if (adjustmentOffset != 0) { + importFixQueue.forEach { entry -> + // Import fixes are added at the top of the file, so all entries need adjustment + // For non-import fixes, only adjust entries after the accepted suggestion + if (wasImportFix || entry.suggestion.startOffset >= startOffset) { + entry.suggestion.startOffset += adjustmentOffset + entry.highlightStartOffset += adjustmentOffset + entry.highlightEndOffset += adjustmentOffset + } + } + // Also adjust suggestionQueue for import fixes (imports added at top affect all code below) + if (wasImportFix) { + suggestionQueue.forEach { + it.adjustOffsets(adjustmentOffset) + } + } + } + suggestionQueue.poll()?.let { + showAutocomplete(it) + } + } + } else { + // Adjust import fix queue offsets if a suggestion was accepted before them + ApplicationManager.getApplication().invokeLater { + // Calculate adjustment offset to shift queued import fixes. + // See comment above for why import fixes cannot use getAdjustmentOffset(). + val adjustmentOffset = + if (wasImportFix) { + document.textLength - docLengthBeforeImportFix + } else { + it.getAdjustmentOffset() + } + + if (adjustmentOffset != 0) { + importFixQueue.forEach { entry -> + // Import fixes are added at the top of the file, so all entries need adjustment + // For non-import fixes, only adjust entries after the accepted suggestion + if (wasImportFix || entry.suggestion.startOffset >= startOffset) { + entry.suggestion.startOffset += adjustmentOffset + entry.highlightStartOffset += adjustmentOffset + entry.highlightEndOffset += adjustmentOffset + } + } + // Also adjust suggestionQueue for import fixes (imports added at top affect all code below) + if (wasImportFix) { + suggestionQueue.forEach { + it.adjustOffsets(adjustmentOffset) + } + } + } + + // No more parts in the multi-part suggestion, try import fixes first + // Move to background thread to avoid EDT slow operations + ApplicationManager.getApplication().executeOnPooledThread { + tryProcessNextImportFix() + } + } + } + } + } + } + + /** + * Accept the next word from the current ghost text suggestion. + * For multi-ghost text, finds the first ghost text at the cursor position. + * Returns true if a word was accepted, false otherwise. + */ + fun acceptNextWord(): Boolean { + val editor = getCurrentEditor() ?: return false + + if (!isCompletionShown) return false + + val caretOffset = editor.caretModel.offset + val document = editor.document + + // Use the helper function to get the appropriate ghost text + val targetGhostText = getGhostTextForWordAcceptance() ?: return false + + // Either: + // 1. caret position is at start of ghost text + // 2. it's one away and the ghost text starts at a newline + + val isOneAway = targetGhostText.isNewlineOnNextLine(caretOffset, document) + + if (!targetGhostText.isAtCaret && !isOneAway) return false + + var content = targetGhostText.content + if (content.isEmpty()) return false + + if (isOneAway) content = "\n" + content + + // Use the helper method to extract the first word + val wordResult = getFirstWord(content) ?: return false + val (nextWord, remainingContent) = wordResult + + invokeLaterIfGatewayModeClient(project) { + WriteCommandAction.runWriteCommandAction(project) { + document.insertString(caretOffset, nextWord) + editor.caretModel.moveToOffset(caretOffset + nextWord.length) + + // If this was the last word in the target ghost text, clear autocomplete + if (remainingContent.isEmpty()) { + clearAutocomplete(AutocompleteDisposeReason.ACCEPTED) + } + } + } + + return true + } + + fun rejectSuggestion() { + clearAutocomplete(AutocompleteDisposeReason.ESCAPE_PRESSED) + + // Check if there are any import fixes to process + ApplicationManager.getApplication().executeOnPooledThread { + tryProcessNextImportFix() + } + + if (IdeaVimIntegrationService.getInstance(project).isIdeaVimActive()) { + getCurrentEditor()?.let { editor -> + val dataContext = DataManager.getInstance().getDataContext(editor.component) + val escHandler = EditorActionManager.getInstance().getActionHandler(ACTION_EDITOR_ESCAPE) + escHandler.execute(editor, editor.caretModel.currentCaret, dataContext) + } + } + } + + private fun getCurrentEditorState(): EditorState? { + if (project.isDisposed) return null + val editor = getCurrentEditor() ?: return null + // Check if document is in bulk update mode and skip if so + if (editor.document.isInBulkUpdate) { + return null + } + val cursorLine = editor.caretModel.logicalPosition.line + 1 + val cursorOffset = + ApplicationManager.getApplication().runReadAction { + editor.caretModel.offset + } + val documentText = editor.document.charsSequence.toString() + val filePath = + FileEditorManager + .getInstance(project) + .selectedFiles + .firstOrNull() + ?.path + ?: return null + + // Compute line prefix efficiently using CharSequence without loading full document again + val charsSequence = editor.document.charsSequence + val safeOffset = cursorOffset.coerceIn(0, charsSequence.length) + val lineStartOffset = (charsSequence.lastIndexOf('\n', (safeOffset - 1).coerceAtLeast(0)) + 1).coerceAtMost(safeOffset) + val currentLinePrefix = charsSequence.subSequence(lineStartOffset, safeOffset).toString() + + return EditorState(documentText, cursorLine, cursorOffset, filePath, editor.document.lineCount, currentLinePrefix) + } + + /** + * Fetches current editor diagnostics from the DocumentMarkupModel. + * This is a lightweight operation that reads already-populated highlights + * without triggering new analysis. + */ + private fun getEditorDiagnostics(): List { + val editor = getCurrentEditor() ?: return emptyList() + val document = editor.document + + return try { + ApplicationManager.getApplication().runReadAction> { + val markupModel = + com.intellij.openapi.editor.impl.DocumentMarkupModel + .forDocument(document, project, false) + ?: return@runReadAction emptyList() + + markupModel.allHighlighters + .mapNotNull { highlighter -> + val highlightInfo = + com.intellij.codeInsight.daemon.impl.HighlightInfo + .fromRangeHighlighter(highlighter) + ?: return@mapNotNull null + + // Only include errors and warnings (severity >= WARNING) + if (highlightInfo.severity.myVal < com.intellij.lang.annotation.HighlightSeverity.WARNING.myVal) { + return@mapNotNull null + } + + val description = + highlightInfo.description?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + + val startOffset = highlightInfo.actualStartOffset.coerceIn(0, document.textLength) + val lineNumber = document.getLineNumber(startOffset) + 1 // 1-based + + // Format: [SEVERITY_TYPE] message + val inspectionId = highlightInfo.inspectionToolId + val formattedMessage = + if (inspectionId != null) { + "[$inspectionId] $description" + } else { + "[${highlightInfo.severity.myName.uppercase()}] $description" + } + + val filePath = getVirtualFileFromEditor(editor)?.path ?: return@mapNotNull null + val key = + TrackedDiagnosticKey( + filePath = filePath, + startOffset = highlightInfo.actualStartOffset, + endOffset = highlightInfo.actualEndOffset, + message = formattedMessage, + ) + + // Get or create tracking info for this diagnostic + val trackingInfo = + trackedDiagnostics.getOrPut(key) { + evictOldDiagnosticsIfNeeded() + TrackedDiagnosticInfo( + timestamp = System.currentTimeMillis(), + ) + } + + EditorDiagnostic( + line = lineNumber, + start_offset = highlightInfo.actualStartOffset, + end_offset = highlightInfo.actualEndOffset, + severity = highlightInfo.severity.myName, + message = formattedMessage, + timestamp = trackingInfo.timestamp, + ) + }.distinctBy { Triple(it.start_offset, it.end_offset, it.message) } + .take(50) // Limit to avoid sending too many diagnostics + } + } catch (e: Exception) { + logger.warn("Failed to get editor diagnostics", e) + emptyList() + } + } + + private fun updateOriginalDocumentText() { + getCurrentEditor()?.let { editor -> + originalDocumentText = editor.document.text + } + } + + /** + * Evicts the oldest diagnostics if the map exceeds the max size. + */ + private fun evictOldDiagnosticsIfNeeded() { + if (trackedDiagnostics.size >= MAX_TRACKED_DIAGNOSTICS) { + // Remove the oldest 10% of entries + val numToRemove = MAX_TRACKED_DIAGNOSTICS / 10 + val oldestKeys = + trackedDiagnostics.entries + .sortedBy { it.value.timestamp } + .take(numToRemove) + .map { it.key } + oldestKeys.forEach { trackedDiagnostics.remove(it) } + } + } + + private fun trackCursorPosition() { + val editorState = getCurrentEditorState() ?: return + val cursorLine = editorState.line + val relativePath = relativePath(project, editorState.filePath) ?: editorState.filePath + + recentCursorPositions.lastOrNull()?.let { lastRecord -> + if (lastRecord.filePath == relativePath && abs(lastRecord.line - cursorLine) < MAX_RECENT_CURSOR_POSITIONS) { + recentCursorPositions.remove(lastRecord) + } + } + recentCursorPositions.add( + CursorPositionRecord( + filePath = relativePath, + line = cursorLine, + cursorOffset = editorState.cursorOffset, + timestamp = System.currentTimeMillis(), + ), + ) + + // Only track cursor movement if it has changed since the last action + val lastAction = recentUserActions.lastOrNull() + val shouldSkipTracking = + lastAction != null && + lastAction.line_number == cursorLine && + lastAction.offset == editorState.cursorOffset && + lastAction.file_path == relativePath + + if (!shouldSkipTracking) { + trackUserAction(UserActionType.CURSOR_MOVEMENT, cursorLine, editorState.cursorOffset, relativePath) + } + } + + private fun detectDocumentChangeActionType(event: com.intellij.openapi.editor.event.DocumentEvent): UserActionType? { + val insertedLength = event.newLength + val deletedLength = event.oldLength + val undoManager = UndoManager.getInstance(project) + if (undoManager.isUndoOrRedoInProgress) { + return null + } + return when { + // Insertion cases + insertedLength > 0 -> { + if (insertedLength == 1) { + UserActionType.INSERT_CHAR // Single character insertion + } else { + UserActionType.INSERT_SELECTION // Multiple characters inserted (paste operation) + } + } + // Deletion cases + deletedLength > 0 -> { + if (deletedLength == 1) { + UserActionType.DELETE_CHAR // Single character deletion + } else { + UserActionType.DELETE_SELECTION // Multiple characters deleted + } + } + + else -> null + } + } + + private fun trackUserAction( + actionType: UserActionType, + lineNumber: Int, + offset: Int, + filePath: String, + ) { + AutocompleteIpResolverService.getInstance(project).updateLastUserActionTimestamp() + + recentUserActions.add( + UserAction( + action_type = actionType, + line_number = lineNumber, + offset = offset, + file_path = filePath, + ), + ) + } + + private fun getRelevantFileChunks(): List { + val fileChunks = mutableListOf() + val processedChunks = mutableSetOf>() // (filePath, startLine) to avoid duplicates + + val currentEditorState = getCurrentEditorState() ?: return emptyList() + val currentFilePath = currentEditorState.let { relativePath(project, it.filePath) ?: it.filePath } + val currentCursorLine = + currentEditorState.let { state -> + val textBeforeCursor = + state.documentText.take(state.cursorOffset.coerceAtMost(state.documentText.length)) + textBeforeCursor.count { it == '\n' } + 1 + } + + val filteredCursorPositions = recentCursorPositions + + for (cursorRecord in filteredCursorPositions.reversed()) { + if (fileChunks.size >= MAX_CHUNKS_TO_SEND) break + + val fileContent = readFile(project, cursorRecord.filePath) ?: continue + if (isFileTooLarge(fileContent, project)) continue + + val lines = fileContent.lines() + + val textBeforeCursor = fileContent.take(cursorRecord.cursorOffset.coerceAtMost(fileContent.length)) + val cursorLine = textBeforeCursor.count { it == '\n' } + 1 // 1-based line number + + val chunkStartLine = + ((cursorLine - 1) / (CHUNK_SIZE_LINES - CHUNK_OVERLAP_LINES)) * (CHUNK_SIZE_LINES - CHUNK_OVERLAP_LINES) + 1 + val chunkKey = Pair(cursorRecord.filePath, chunkStartLine) + + if (chunkKey in processedChunks) continue + + val endLine = minOf(chunkStartLine + CHUNK_SIZE_LINES - 1, lines.size) + + if (cursorRecord.filePath == currentFilePath && currentCursorLine in chunkStartLine..endLine) { + continue + } + + val chunkLines = lines.subList(chunkStartLine - 1, endLine) // Convert to 0-based for subList + val chunkContent = chunkLines.joinToString("\n") + + fileChunks.add( + FileChunk( + file_path = cursorRecord.filePath, + start_line = chunkStartLine, + end_line = endLine, + content = chunkContent, + timestamp = cursorRecord.timestamp, + ), + ) + + processedChunks.add(chunkKey) + } + + return fileChunks.sortedBy { it.timestamp }.takeLast(MAX_CHUNKS_TO_SEND) + } + + private fun hasMultiLineSelection(): Boolean { + val editor = getCurrentEditor() ?: return false + + return ApplicationManager.getApplication().runReadAction { + val selectionModel = editor.selectionModel + + if (!selectionModel.hasSelection()) return@runReadAction false + + val document = editor.document + val selectionStart = selectionModel.selectionStart + val selectionEnd = selectionModel.selectionEnd + + val startLine = document.getLineNumber(selectionStart) + val endLine = document.getLineNumber(selectionEnd) + + endLine > startLine + } + } + + /** + * Check if the given file path matches any of the autocomplete exclusion patterns + */ + private fun shouldExcludeFromAutocomplete(filePath: String): Boolean { + val exclusionPatterns = OxideCodeConfig.getInstance(project).getAutocompleteExclusionPatterns() + if (exclusionPatterns.isEmpty()) return false + + val fileName = File(filePath).name + + return exclusionPatterns.any { pattern -> + matchesExclusionPattern(fileName, pattern) + } + } + + /** + * Check if the user is currently in a template or refactoring UI + * (e.g., live templates, inline rename, etc.) + */ + private fun isInTemplateUI(): Boolean { + val editor = getCurrentEditor() ?: return false + + // Check if a live template is active + val templateState = + com.intellij.codeInsight.template.impl.TemplateManagerImpl + .getTemplateState(editor) + if (templateState != null && !templateState.isFinished) { + return true + } + + return false + } + + fun processLatestEdit() { + currentJob?.cancel() + currentJob = + scope.launch { + if (getCurrentEditor()?.document?.isWritable == false) { + return@launch + } + + val editorState = getCurrentEditorState() ?: return@launch + + // Check if there are multi-line selections active + if (hasMultiLineSelection()) { + return@launch + } + + // Check if current file should be excluded from autocomplete + if (shouldExcludeFromAutocomplete(editorState.filePath)) { + return@launch + } + + // Check if user is in template or refactoring UI + if (isInTemplateUI()) { + return@launch + } + + FileEditorManager.getInstance(project).selectedFiles.firstOrNull()?.let { +// if (!it.isInLocalFileSystem) return@launch + } ?: return@launch + + val requestEntry = + AutocompleteRequestEntry( + editorState = editorState, + ) + + val deferred = CompletableDeferred>() + fetchAutocompleteRequest(requestEntry, deferred) + } + } + + private fun fetchAutocompleteRequest( + requestEntry: AutocompleteRequestEntry, + deferred: CompletableDeferred>, + ) = ioScope.launch { + try { + mutex.withLock { + // Cancel all previous requests + fetchJobs.values.forEach { it.cancel() } + fetchJobs.clear() + + // Add the new request + fetchJobs[requestEntry.requestTime] = deferred + } +// println("Sending request: ${requestEntry.id} at time ${requestEntry.requestTime}") + val response = + fetchNextEditAutocomplete( + filePath = requestEntry.editorState.filePath, + fileContents = requestEntry.editorState.documentText, + caretPosition = requestEntry.editorState.cursorOffset, + )?.apply { adjustIndices(requestEntry.editorState.documentText) } + // println("Received response: ${response?.autocomplete_id} in ${System.currentTimeMillis() - requestEntry.requestTime}") + deferred.complete(requestEntry to response) + completionChannel.send(requestEntry to response) + } catch (e: Exception) { + // println("Error fetching autocomplete: ${e.message}") + deferred.complete(requestEntry to null) + completionChannel.send(requestEntry to null) + } finally { + mutex.withLock { + fetchJobs.remove(requestEntry.requestTime) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun launchAutocompleteConsumerWorker() { + // while loop that polls for received completions and decides whether to display them + consumerJob?.cancel() + consumerJob = + scope.launch { + while (isActive) { + try { + val (request, response) = completionChannel.receive() + response ?: continue +// println("Fetch jobs size: ${fetchJobs.size}") + + // First check if a change has already been proposed: + if (currentSuggestion != null) continue + + // Third check if there are multi-line selections active + if (hasMultiLineSelection()) continue + + // Then either it's the last request sent or it's an extension of the added code: + val isLatestRequest = + mutex.withLock { + val maxTime = ( + fetchJobs.values.maxOfOrNull { + if (it.isCompleted) { + it.getCompleted().first.requestTime + } else { + 0L + } + } ?: 0L + ) + request.requestTime >= maxTime + } + if (!isLatestRequest) { + val isValidGhostText = checkForGhostTextExtension(request, response) + if (isValidGhostText) { + request.editorState = getCurrentEditorState() ?: continue + } else { + continue + } + } + + // If so, cancel all fetch requests + mutex.withLock { + fetchJobs.values.forEach { it.cancel() } + fetchJobs.clear() + } + ApplicationManager.getApplication().invokeLater { + response.completions.firstOrNull()?.let { firstResponse -> + suggestionQueue.clear() + response.completions.drop(1).forEach { suggestionQueue.add(it) } + showAutocomplete(firstResponse, request.editorState) + } ?: run { + + } + } + } catch (e: Exception) { + println("Error in request consumer: ${e.message}") + } + } + } + } + + private fun checkForGhostTextExtension( + request: AutocompleteRequestEntry, + response: NextEditAutocompleteResponse, + ): Boolean { + // Check if the suggestion extends the user's inserted text + val currentState = getCurrentEditorState() + val currentDocumentText = currentState?.documentText ?: return false + val userInsertedText = request.editorState.returnInsertionTextOrNull(currentDocumentText) ?: return false + if (userInsertedText.isEmpty()) return false + val suggestedText = + response.completions.firstOrNull()?.applyChangesTo(request.editorState.documentText) ?: return false + val suggestedInsertedText = request.editorState.returnInsertionTextOrNull(suggestedText) ?: return false + if (suggestedInsertedText.isEmpty()) return false + if (suggestedInsertedText.startsWith(userInsertedText)) { + response.completions.apply { + firstOrNull()?.apply { + completion = suggestedInsertedText.removePrefix(userInsertedText) + start_index = currentState.cursorOffset + end_index = currentState.cursorOffset + } + drop(1).forEach { it.adjustOffsets(userInsertedText.length) } + } + return true + } + return false + } + + private fun showAutocomplete( + response: NextEditAutocompletion, + requestState: EditorState? = null, + isShowingPostJumpSuggestion: Boolean = false, + ) { + val previousState = requestState ?: getCurrentEditorState() ?: return + + ApplicationManager.getApplication().invokeLater { + clearAutocomplete(AutocompleteDisposeReason.CLEARING_PREVIOUS_AUTOCOMPLETE) + + // Validations: + + // Check if the editor is focused + val currentEditor = getCurrentEditor() ?: return@invokeLater + if (!currentEditor.contentComponent.isFocusOwner) { + return@invokeLater + } + + // Validate state didn't change between when it was first suggested and now + if (currentEditor.caretModel.offset != previousState.cursorOffset || + currentEditor.document.text != previousState.documentText + ) { + return@invokeLater + } + + // Validate that it's proposing a non-trivial change + val oldContent = + ApplicationManager.getApplication().runReadAction { + val docText = currentEditor.document.charsSequence + if (response.end_index > docText.length) { + null + } else { + docText.subSequence( + response.start_index, + response.end_index, + ) + } + } ?: return@invokeLater + if (oldContent.toString().trim('\n') == response.completion.trim('\n')) { + return@invokeLater + } + + debouncer.cancel() + + // Show the suggestion + AutocompleteSuggestion + .fromAutocompleteResponse( + response = response, + editor = currentEditor, + project = project, + )?.apply { + onDispose = { + clearAutocomplete(AutocompleteDisposeReason.AUTOCOMPLETE_DISPOSED) + } + // Set retrieval counts for metrics tracking + numDefinitionsRetrieved = lastNumDefinitionsRetrieved + numUsagesRetrieved = lastNumUsagesRetrieved + }?.let { + // Handle rejection caching + if (( + AutocompleteRejectionCache.getInstance(project).checkIfSuggestionShouldBeShown(it) || + isShowingPostJumpSuggestion + ) || + getVirtualFileFromEditor(currentEditor)?.name?.endsWith("tutorial.py") == true + ) { + acceptanceDisposable?.dispose() + acceptanceDisposable = null + currentSuggestion?.dispose() + currentSuggestion = it + + it.show(currentEditor, isShowingPostJumpSuggestion) + + it.shownTime = System.currentTimeMillis() + } else { + it.dispose() + } + } + } + } + + private fun getOtherOpenedFileChunks(): List { + val openedFiles = FileEditorManager.getInstance(project).selectedFiles + val currentEditorPath = getCurrentEditor()?.virtualFile?.path?.let { relativePath(project, it) ?: it } + return openedFiles.mapNotNull { virtualFile -> + val virtualFileRelativePath = relativePath(project, virtualFile.path) ?: virtualFile.path + if (virtualFileRelativePath == currentEditorPath) return@mapNotNull null + + val editor = + FileEditorManager + .getInstance( + project, + ).getSelectedEditor(virtualFile) as? com.intellij.openapi.fileEditor.TextEditor + val textEditor = editor?.editor + + if (textEditor != null) { + getVisibleFileChunk(textEditor, project) + } else { + // Fallback: create chunk for entire file + val relativePath = virtualFileRelativePath + val fileContent = readFile(project, relativePath) ?: return@mapNotNull null + val lines = fileContent.lines() + + FileChunk( + file_path = relativePath, + start_line = 1, + end_line = lines.size, + content = fileContent, + timestamp = System.currentTimeMillis(), + ) + } + } + } + + private suspend fun fetchNextEditAutocomplete( + filePath: String, + fileContents: String, + caretPosition: Int, + ): NextEditAutocompleteResponse? { + try { + val repoName = userSpecificRepoName(project) + val originalFileContents = originalDocumentText + if (isFileTooLarge(originalFileContents, project)) { + logger.warn("File is too large to fetch next edit autocomplete") + return null + } + val fileChunks = getRelevantFileChunks() + val otherOpenedFileChunks = getOtherOpenedFileChunks() + val clipboardText = getClipboardEntry() + val clipboardChunks = + clipboardText + ?.takeIf { + it.content.isNotBlank() && + it.getDuration() < 1000 * 60 && + // Validate by number of lines, not characters + it.content.lines().size <= MAX_CLIPBOARD_LINES + }?.let { + listOf( + FileChunk( + file_path = "clipboard.txt", + start_line = 1, + end_line = minOf(it.content.lines().size, MAX_CLIPBOARD_LINES), + content = + it + .content + .trim() + .lines() + .take(MAX_CLIPBOARD_LINES) + .joinToString("\n"), + timestamp = System.currentTimeMillis(), + ), + ) + } ?: emptyList() + val allFileChunks = fileChunks + otherOpenedFileChunks + val relPath = relativePath(project, filePath) ?: filePath + var retrievalChunks = emptyList() + getCurrentEditorState()?.let { editorState -> + val currentDropDownContents = + runCatching { + entityUsageSearchService.getCurrentDropdownContents()?.takeIf { it.isNotEmpty() }?.let { + listOf( + FileChunk( + file_path = "dropdown.txt", + start_line = 1, + end_line = it.lines().size, + content = it, + timestamp = System.currentTimeMillis(), + ), + ) + } ?: emptyList() + }.getOrElse { emptyList() } + + // Feature flag: when enabled, use the cache for definition chunks + // When disabled, fetch definitions synchronously (no caching) + val useDefinitionCache = false + val definitionChunks = + if (useDefinitionCache) { + definitionChunkCache.getOrFetch(editorState) + } else { + runCatching { + entityUsageSearchService.getDefinitionsBeforeCursor(editorState) + }.getOrElse { emptyList() } + } + + val usageChunks = + runCatching { + entityUsageSearchService.getCurrentLineEntityUsages(editorState) + }.getOrElse { emptyList() } + + // Store retrieval counts for metrics tracking + lastNumDefinitionsRetrieved = definitionChunks.size + lastNumUsagesRetrieved = usageChunks.size + + retrievalChunks = + ( + currentDropDownContents + + clipboardChunks + + usageChunks + + definitionChunks + ).onEach { + it.truncate(MAX_RETRIEVAL_CHUNK_SIZE) + }.filter { + it.file_path != relPath + }.let { snippets -> + fuseAndDedupSnippets( + project, + snippets, + ) + }.reversed() + } + + val request = + NextEditAutocompleteRequest( + repo_name = repoName, + file_path = relPath, + file_contents = fileContents, + recent_changes = + recentEdits + .toList() + .takeLast(RECENT_CHANGES_TO_SEND) + .map { it.formattedDiff } + .filter { it.length <= MAX_DIFF_HUNK_SIZE } + .joinToString("\n"), + cursor_position = caretPosition, + original_file_contents = originalFileContents, + file_chunks = allFileChunks, + retrieval_chunks = retrievalChunks, + recent_user_actions = recentUserActions.toList(), + multiple_suggestions = true, + privacy_mode_enabled = OxideCodeConfig.getInstance(project).isPrivacyModeEnabled(), + recent_changes_high_res = + recentEditsHighRes + .toList() + .takeLast( + HIGH_RES_RECENT_CHANGES_TO_SEND, + ).map { it.formattedDiff } + .filter { it.length <= MAX_DIFF_HUNK_SIZE } + .joinToString("\n"), + changes_above_cursor = true, + editor_diagnostics = getEditorDiagnostics(), + ) + + val startTime = System.currentTimeMillis() + + val result = AutocompleteIpResolverService.getInstance(project).fetchNextEditAutocomplete(request) + + val wallTime = System.currentTimeMillis() - startTime + val serverTime = result?.elapsed_time_ms ?: Long.MAX_VALUE + val overhead = wallTime - serverTime + + logger.info("Fetched next edit autocomplete in ${wallTime}ms (server: ${serverTime}ms, overhead: ${overhead}ms)") + + return result + } catch (e: Exception) { + // println("Error fetching next edit autocomplete: ${e.message}") + e.printStackTrace() + + val stackTrace = e.stackTraceToString().take(500) // Limit stack trace length + NotificationDeduplicationService.getInstance(project).showNotificationWithDeduplicationAndErrorReporting( + title = "Autocomplete Error", + content = "Failed to fetch next edit autocomplete: ${e.message}\n\nStack trace:\n$stackTrace", + notificationGroup = "Sweep Autocomplete", + type = NotificationType.ERROR, + exception = e, + errorContext = "Autocomplete fetch failed: ${e.message}", + ) + return null + } + } + + fun clearAutocomplete(autocompleteDisposeReason: AutocompleteDisposeReason) { + currentSuggestion?.disposedTime = System.currentTimeMillis() + if (currentSuggestion?.suggestionWasShownAtAll() == true) { + AutocompleteRejectionCache + .getInstance(project) + .tryAddingRejectionToCache(currentSuggestion!!, autocompleteDisposeReason) + } + currentSuggestion?.let { Disposer.dispose(it) } + currentSuggestion = null + } + + /** + * Tries to process the next import fix suggestion from the queue if available and fresh enough + * @return true if an import fix was shown, false otherwise + */ + private fun tryProcessNextImportFix(): Boolean { + if (project.isDisposed) return false + + // Process queue until we find a valid suggestion or queue is empty + val currentTime = System.currentTimeMillis() + while (importFixQueue.isNotEmpty()) { + val entry = importFixQueue.peek() + if (entry != null && (currentTime - entry.createdAt) > IMPORT_FIX_FRESHNESS_MS) { + // Entry is too old, remove and dispose it + importFixQueue.poll()?.suggestion?.dispose() + } else { + // Entry is fresh (or queue is empty), stop removing + break + } + } + + // Get the next fresh entry + val nextEntry = importFixQueue.poll() + val recentlyAccepted = + acceptedImportFixes.any { + it.content == nextEntry?.suggestion?.content && (currentTime - it.timestamp) < 2000 + } + if (nextEntry != null && !nextEntry.suggestion.editor.isDisposed && !recentlyAccepted) { + // First, validate that the text at the highlight range still matches + // This ensures the document hasn't changed since the import fix was queued + val document = nextEntry.suggestion.editor.document + val startOffset = nextEntry.highlightStartOffset + val endOffset = nextEntry.highlightEndOffset + val expectedText = nextEntry.expectedText + + val actualText = + ApplicationManager.getApplication().runReadAction { + if (startOffset < 0 || endOffset > document.textLength || startOffset >= endOffset) { + null + } else { + document.charsSequence.subSequence(startOffset, endOffset).toString() + } + } + + if (actualText != expectedText) { + nextEntry.suggestion.dispose() + return false + } + + // Verify the IntentionAction is still valid before showing + val intentionAction = nextEntry.suggestion.importFixIntentionAction + if (intentionAction != null) { + // Check if the intention action is still available/valid + val isValid = + ApplicationManager.getApplication().runReadAction { + val psiFile = + com.intellij.psi.PsiDocumentManager + .getInstance(project) + .getPsiFile(document) + + psiFile?.let { + try { + intentionAction.isAvailable(project, nextEntry.suggestion.editor, psiFile) + } catch (e: Exception) { + logger.warn("Failed to check if IntentionAction is available: ${e.message}") + false + } + } ?: false + } + + if (!isValid) { + nextEntry.suggestion.dispose() + return false + } + } else { + logger.warn("Import fix suggestion has no IntentionAction associated") + nextEntry.suggestion.dispose() + return false + } + + // Show the next import fix + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + currentSuggestion = nextEntry.suggestion + nextEntry.suggestion.show(nextEntry.suggestion.editor, isPostJumpSuggestion = false) + nextEntry.suggestion.shownTime = System.currentTimeMillis() + } + return true + } + return false + } + + /** + * Shows an import fix suggestion in the autocomplete system + * Called by AutocompleteImportDetector when imports are needed + * + * @param suggestion The popup suggestion to show + * @param expectedText The text that should be at the highlight range for validation + * @param highlightStartOffset Start offset of the unresolved reference + * @param highlightEndOffset End offset of the unresolved reference + */ + fun queueAndTryToShowImportFixSuggestion( + suggestion: AutocompleteSuggestion.PopupSuggestion, + expectedText: String, + highlightStartOffset: Int, + highlightEndOffset: Int, + ) { + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed || suggestion.editor.isDisposed) return@invokeLater + + // Check if this import fix has already been accepted within last 2 seconds + val currentTime = System.currentTimeMillis() + val recentlyAccepted = + acceptedImportFixes.any { + it.content == suggestion.content && (currentTime - it.timestamp) < 2000 + } + if (recentlyAccepted) { + // This import fix was accepted less than 2 seconds ago, don't show it again + suggestion.dispose() + return@invokeLater + } + + // Check if we currently have a suggestion showing + val current = currentSuggestion + + // Queue the import fix with validation data + importFixQueue.add( + ImportFixQueueEntry( + suggestion = suggestion, + expectedText = expectedText, + highlightStartOffset = highlightStartOffset, + highlightEndOffset = highlightEndOffset, + ), + ) + + // If there's no current suggestion, try to show this one immediately + if (current == null) { + // Move to background thread to avoid EDT slow operations + ApplicationManager.getApplication().executeOnPooledThread { + tryProcessNextImportFix() + } + } + } + } + + override fun dispose() { + isDisposed = true + + clearAutocomplete(AutocompleteDisposeReason.AUTOCOMPLETE_DISPOSED) + acceptanceDisposable?.dispose() + acceptanceDisposable = null + + // Clean up import fix queue + while (importFixQueue.isNotEmpty()) { + importFixQueue.poll()?.suggestion?.dispose() + } + + acceptedImportFixes.clear() + + // Clear diagnostic tracking + trackedDiagnostics.clear() + + // Dispose lookup UI customizer + lookupUICustomizer?.dispose() + lookupUICustomizer = null + + // NEW: Clean up focus tracking + cleanupFocusTracking() + + // Clean up listeners properly + currentListener?.let { listener -> + currentDocument?.runCatching { removeDocumentListener(listener) } + } + currentListener = null + currentDocument = null + + currentCaretListener?.let { listener -> + currentEditorWithListeners?.caretModel?.runCatching { removeCaretListener(listener) } + } + currentCaretListener = null + + currentFocusListener?.let { listener -> + currentEditorWithListeners?.contentComponent?.runCatching { removeFocusListener(listener) } + } + currentFocusListener = null + + // Clean up window focus listener disposable + windowFocusListenerDisposable?.let { Disposer.dispose(it) } + windowFocusListenerDisposable = null + + currentEditorWithListeners = null + + // Clear editor references to prevent memory leaks + lastFocusedEditor = null + + // Cancel all coroutine jobs + currentJob?.cancel() + currentJob = null + + consumerJob?.cancel() + consumerJob = null + + // Clear fetch jobs synchronously + fetchJobs.forEach { (_, deferred) -> + if (!deferred.isCompleted) { + deferred.cancel() + } + } + fetchJobs.clear() + + completionChannel.close() + + // Cancel all coroutine scopes to prevent memory leaks + trackerJob.cancel() + ioJob.cancel() + listenerJob.cancel() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/components/TruncatedLabel.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/components/TruncatedLabel.kt new file mode 100644 index 0000000..682cc4c --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/components/TruncatedLabel.kt @@ -0,0 +1,126 @@ +package com.oxidecode.components + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.util.ui.UIUtil +import com.oxidecode.utils.ComponentResizedAdapter +import com.oxidecode.utils.calculateTextLength +import com.oxidecode.utils.calculateTruncatedText +import com.oxidecode.utils.scaled +import java.awt.Dimension +import java.awt.Graphics +import java.awt.event.ComponentListener +import javax.swing.Icon +import javax.swing.JLabel +import kotlin.math.max + +open class TruncatedLabel( + var initialText: String, + parentDisposable: Disposable, + private var leftIcon: Icon? = null, + private var rightIcon: Icon? = null, +) : JLabel(), + Disposable { + companion object { + private var horizontalPadding = 4.scaled // Reduced padding for tighter layout + private val defaultIconWidth = 16.scaled + private val iconTextGap = 4.scaled // Gap between icon and text + } + + private var resizeListener: ComponentListener? = null + + init { + Disposer.register(parentDisposable, this) + text = "" + icon = leftIcon + horizontalAlignment = LEFT + foreground = UIUtil.getLabelForeground() + updateText() + + resizeListener = + ComponentResizedAdapter { + updateText() + revalidate() + repaint() + } + addComponentListener(resizeListener) + } + + private fun updateText() { + val leftIconWidth = leftIcon?.iconWidth ?: 0 + val leftIconGap = if (leftIcon != null) iconTextGap else 0 + val rightIconWidth = rightIcon?.iconWidth ?: 0 + val rightIconGap = if (rightIcon != null) iconTextGap else 0 + + // Guard: during first render width is 0 -> do not truncate yet + if (width <= 0) { + text = initialText + return + } + + val availableWidth = width - horizontalPadding - leftIconWidth - leftIconGap - rightIconWidth - rightIconGap + text = calculateTruncatedText(initialText, availableWidth, getFontMetrics(font)) + } + + override fun getPreferredSize(): Dimension { + val fm = getFontMetrics(font) + val textW = calculateTextLength(initialText, fm) + val leftW = leftIcon?.iconWidth ?: 0 + val rightW = rightIcon?.iconWidth ?: 0 + val leftGap = if (leftIcon != null) iconTextGap else 0 + val rightGap = if (rightIcon != null) iconTextGap else 0 + val w = horizontalPadding + leftW + leftGap + textW + rightGap + rightW + val h = max(fm.height, max(leftIcon?.iconHeight ?: 0, rightIcon?.iconHeight ?: 0)) + val ins = insets + return Dimension(w + ins.left + ins.right, h + ins.top + ins.bottom) + } + + fun updateInitialText(newText: String) { + initialText = newText + updateText() + } + + fun updateIcon(newIcon: Icon?) { + leftIcon = newIcon + icon = leftIcon + updateText() + revalidate() + repaint() + } + + fun updateRightIcon(newIcon: Icon?) { + rightIcon = newIcon + updateText() + revalidate() + repaint() + } + + override fun paint(g: Graphics) { + super.paint(g) + + // Draw the right icon if it exists + rightIcon?.let { icon -> + val iconY = (height - icon.iconHeight) / 2 + + // Calculate position after the text + val leftIconWidth = leftIcon?.iconWidth ?: 0 + val leftIconGap = if (leftIcon != null) iconTextGap else 0 + val textWidth = calculateTextLength(text, getFontMetrics(font)) + val iconX = horizontalPadding / 2 + leftIconWidth + leftIconGap + textWidth + iconTextGap + + icon.paintIcon(this, g, iconX, iconY) + } + } + + override fun addNotify() { + super.addNotify() + updateText() + } + + override fun dispose() { + resizeListener?.let { + removeComponentListener(it) + resizeListener = null + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/controllers/OxideCodeGhostText.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/controllers/OxideCodeGhostText.kt new file mode 100644 index 0000000..d6c1065 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/controllers/OxideCodeGhostText.kt @@ -0,0 +1,431 @@ +package com.oxidecode.controllers + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.util.Alarm +import com.intellij.util.messages.Topic +import com.oxidecode.services.CodeEntityExtractor +import com.oxidecode.settings.OxideCodeConfig +import com.oxidecode.utils.KeyPressedAdapter +import com.oxidecode.views.RoundedTextArea +import java.awt.event.FocusEvent +import java.awt.event.FocusListener +import java.awt.event.KeyEvent +import java.awt.event.KeyListener +import javax.swing.event.CaretEvent +import javax.swing.event.CaretListener +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener + +/** + * [OxideCodeGhostText] listens to any text change in the [TextFieldComponent] and + * suggests entity names (classes, functions, properties, etc.) from the currently + * focused code file as ghost text completions. + * + * It displays the most frequently used matching entity as ghost text in the text field. + * This uses a simple Bayesian prior: entities that appear more often in the file + * are more likely to be what the user wants to type. + */ +@Service(Service.Level.PROJECT) +class OxideCodeGhostText( + private val project: Project, +) : Disposable { + companion object { + val GHOST_TEXT_TOPIC = Topic.create("OxideCode Ghost Text Changes", GhostTextListener::class.java) + + fun getInstance(project: Project): OxideCodeGhostText = project.getService(OxideCodeGhostText::class.java) + } + + interface GhostTextListener { + fun onGhostTextChanged() + } + + private val alarm: Alarm = Alarm(Alarm.ThreadToUse.POOLED_THREAD, this) + private val entityExtractor = CodeEntityExtractor.getInstance(project) + private val SWEEP_GHOST_TEXT_DELAY = 100L + + // Keep track of attached listeners + private val attachedListeners = mutableMapOf() + + // Container for all listeners attached to a specific text area + private data class ListenerContainer( + val keyListener: KeyListener, + val documentListener: DocumentListener, + val focusListener: FocusListener, + val caretListener: CaretListener, + ) + + private var activeHolder: RoundedTextArea? = null + private var lastGhostText: String = "" + + // Track the full matched entity name for optimization (skip search if user types matching chars) + private var lastBestMatch: String = "" + + /** + * Refresh entity cache when the focused file changes. + * Uses the two-tier system from CodeEntityExtractor. + */ + private fun refreshEntitiesIfNeeded() { + // Skip if entity suggestions are disabled via config + if (!OxideCodeConfig.getInstance(project).isEntitySuggestionsEnabled()) { + return + } + + // Only refresh if the file has changed + if (entityExtractor.hasFileChanged()) { + entityExtractor.refreshEntities() + } + } + + /** + * Find all matching entities for the given prefix. + * Returns entities that match (prioritized by tier: viewport first, then secondary). + */ + private fun findMatches(prefix: String): List { + if (prefix.isEmpty()) return emptyList() + + val startTime = System.nanoTime() + + val lowercasePrefix = prefix.lowercase() + + // Get entities with priority ordering (Tier 1 viewport first, then Tier 2 secondary) + val entities = entityExtractor.getEntityNames() + + // Find all entities that start with the prefix (case-insensitive) + // Already sorted by priority (viewport first) and frequency within each tier + val results = + entities.filter { entity -> + entity.lowercase().startsWith(lowercasePrefix) && + entity.length > prefix.length // Must be longer than what user typed + } + + return results + } + + /** + * Find the best matching entity for the given prefix. + * Returns the first matching entity (prioritized by tier: viewport first, then secondary). + */ + private fun findBestMatch(prefix: String): String? = findMatches(prefix).firstOrNull() + + private fun hasGhostText(): Boolean = + activeHolder + ?.let { holder -> + val holderTextLength = holder.text.trim().length + val ghostTextLength = lastGhostText.length + ghostTextLength > holderTextLength + } ?: false + + fun isGhostTextVisible(): Boolean = hasGhostText() + + /** + * Explicitly clears any ghost text currently associated with the given [holder]. + * + * This is primarily used when a message is "sent" from a [RoundedTextArea] that + * we keep displayed (e.g. resending from a [UserMessageComponent]). In that flow + * the document text does not change, so our normal document listeners don't get + * a chance to clear the suggestion. + * + * If [holder] is null, this is a no-op. + */ + fun clearGhostText(holder: RoundedTextArea?) { + val targetHolder = holder ?: return + + // Cancel any pending suggestion requests to prevent them from re-setting ghost text + alarm.cancelAllRequests() + + lastGhostText = "" + lastBestMatch = "" + targetHolder.setGhostText("") + targetHolder.setFullGhostText("") + + if (!project.isDisposed) { + project.messageBus.syncPublisher(GHOST_TEXT_TOPIC).onGhostTextChanged() + } + } + + fun attachGhostTextTo(holder: RoundedTextArea) { + // Detach any existing listeners first + detachGhostTextFrom(holder) + + val keyListener = + KeyPressedAdapter { e -> + if (e.keyCode == KeyEvent.VK_TAB) { + if (holder.caretPosition > 0 && holder.text[holder.caretPosition - 1] != '@') { + // Accept full ghost text + holder.acceptGhostText() + } + e.consume() + } + } + + val documentListener = + object : DocumentListener { + override fun insertUpdate(e: DocumentEvent) = scheduleSuggestion(holder) + + override fun removeUpdate(e: DocumentEvent) = scheduleSuggestion(holder) + + override fun changedUpdate(e: DocumentEvent) = scheduleSuggestion(holder) + } + + val focusListener = + object : FocusListener { + override fun focusGained(e: FocusEvent?) { + activeHolder = holder + } + + override fun focusLost(e: FocusEvent?) { + if (activeHolder == holder) { + activeHolder = null + } + } + } + + val caretListener = + object : CaretListener { + override fun caretUpdate(e: CaretEvent?) { + val text = holder.text + val caretPos = e?.dot ?: holder.caretPosition + + // Check if caret is at the end of the text (only whitespace follows) + val textAfterCaret = if (caretPos < text.length) text.substring(caretPos) else "" + val isAtEndOfText = textAfterCaret.isBlank() + + // Check if caret is at the end of a word + // (after an alphanumeric char or underscore and before whitespace or end of text) + val isAtEndOfWord = + caretPos > 0 && + ( + text.getOrNull(caretPos - 1)?.isLetterOrDigit() == true || + text.getOrNull(caretPos - 1) == '_' + ) && + (caretPos >= text.length || text.getOrNull(caretPos)?.isWhitespace() == true) + + // Only show ghost text if at end of text AND at end of a word + val shouldShowGhostText = isAtEndOfText && isAtEndOfWord + + if (!shouldShowGhostText && lastGhostText.isNotEmpty()) { + // Caret moved away from end of text/word - clear ghost text + lastGhostText = "" + lastBestMatch = "" + holder.setGhostText("") + holder.setFullGhostText("") + if (!project.isDisposed) { + project.messageBus.syncPublisher(GHOST_TEXT_TOPIC).onGhostTextChanged() + } + } else if (shouldShowGhostText && lastGhostText.isEmpty() && text.isNotEmpty()) { + // Caret moved to end of text at a word - re-trigger search for suggestions + scheduleSuggestion(holder) + } + } + } + + // Attach listeners + holder.textArea.addKeyListener(keyListener) + holder.addDocumentListener(documentListener) + holder.textArea.addFocusListener(focusListener) + holder.textArea.addCaretListener(caretListener) + + // Store references to listeners + attachedListeners[holder] = ListenerContainer(keyListener, documentListener, focusListener, caretListener) + Disposer.register(holder, Disposable { detachGhostTextFrom(holder) }) + } + + private fun detachGhostTextFrom(holder: RoundedTextArea) { + attachedListeners[holder]?.let { container -> + holder.textArea.removeKeyListener(container.keyListener) + holder.textArea.document.removeDocumentListener(container.documentListener) + holder.textArea.removeFocusListener(container.focusListener) + holder.textArea.removeCaretListener(container.caretListener) + attachedListeners.remove(holder) + + // Clear ghost text if this is the active holder + if (activeHolder == holder) { + clearGhostText(holder) + activeHolder = null + } + } + } + + private fun scheduleSuggestion(holder: RoundedTextArea) { + // Return early if project is disposed + if (project.isDisposed) return + + // Only show suggestions when the text area has focus + if (!holder.textArea.hasFocus()) return + + // Always cancel pending requests first to prevent race conditions + alarm.cancelAllRequests() + + val currentText = holder.text.trim() + alarm.addRequest({ + val config = OxideCodeConfig.getInstance(project) + if (!config.isEntitySuggestionsEnabled()) { + ApplicationManager.getApplication().invokeLater { + if (lastGhostText.isNotEmpty()) { + lastGhostText = "" + lastBestMatch = "" + holder.setGhostText("") + } + } + return@addRequest + } + + // Refresh entities if needed (file may have changed) + refreshEntitiesIfNeeded() + + if (currentText.isNotEmpty()) { + // Get the word at caret position (entity names are single words) + val caretPos = holder.caretPosition + val text = holder.text + + // Only show ghost text if caret is at the end of the text (or only whitespace follows) + val textAfterCaret = if (caretPos < text.length) text.substring(caretPos) else "" + val isAtEndOfText = textAfterCaret.isBlank() + if (!isAtEndOfText) { + ApplicationManager.getApplication().invokeLater { + if (lastGhostText.isNotEmpty()) { + lastGhostText = "" + lastBestMatch = "" + holder.setGhostText("") + holder.setFullGhostText("") + if (!project.isDisposed) { + project.messageBus.syncPublisher(GHOST_TEXT_TOPIC).onGhostTextChanged() + } + } + } + return@addRequest + } + + // Find the start of the word at caret position + // Look backwards to the earliest non alphanumeric or _ character + var wordStart = caretPos + while (wordStart > 0 && + (text.getOrNull(wordStart - 1)?.isLetterOrDigit() == true || text.getOrNull(wordStart - 1) == '_') + ) { + wordStart-- + } + val wordAtCaret = if (caretPos > wordStart) text.substring(wordStart, caretPos) else "" + + // Optimization: Check if user is typing characters that match the current suggestion + // If so, we can skip the search entirely + val canSkipSearch = + lastBestMatch.isNotEmpty() && + wordAtCaret.length >= 3 && + lastBestMatch.lowercase().startsWith(wordAtCaret.lowercase()) && + lastBestMatch.length > wordAtCaret.length + + val bestMatch = + if (canSkipSearch) { + // User is typing chars that continue to match - reuse the same match + lastBestMatch + } else if (wordAtCaret.length >= 3) { + // Need to do a fresh search + val matches = findMatches(wordAtCaret) + val candidate = matches.firstOrNull() + + // Check if we're already showing a valid suggestion + // If so, keep showing it regardless of non-alphanumeric rules + val isAlreadyShowingSuggestion = lastGhostText.isNotEmpty() + // lastGhostText is just the completion part, so wordAtCaret + lastGhostText should equal candidate + val currentSuggestionStillValid = + isAlreadyShowingSuggestion && + candidate != null && + (wordAtCaret + lastGhostText) == candidate + + if (currentSuggestionStillValid) { + // Keep showing the current valid suggestion + candidate + } else if (isAlreadyShowingSuggestion && candidate != null) { + // We're showing a suggestion but it changed - allow the new one + candidate + } else { + // Not showing a suggestion yet - apply non-alphanumeric filtering rules + val lastChar = wordAtCaret.lastOrNull() + val isLastCharAlphanumeric = lastChar?.isLetterOrDigit() == true + + if (isLastCharAlphanumeric) { + // For alphanumeric characters, check if completion starts with non-alphanumeric + if (candidate != null) { + val completionStart = candidate.getOrNull(wordAtCaret.length) + val isCompletionStartAlphanumeric = completionStart?.isLetterOrDigit() == true + if (isCompletionStartAlphanumeric || matches.size == 1) { + candidate + } else { + // Completion starts with non-alphanumeric and multiple matches - don't suggest + null + } + } else { + null + } + } else { + // For non-alphanumeric (like '_'), only show if there's exactly one match + if (matches.size == 1) matches.first() else null + } + } + } else { + null + } + + ApplicationManager.getApplication().invokeLater { + if (Disposer.isDisposed(holder)) return@invokeLater + + if (bestMatch != null && bestMatch.length > wordAtCaret.length) { + // Ghost text should only show the completion part (what gets added after caret) + val completionPart = bestMatch.substring(wordAtCaret.length) + // Full suggestion is the entire text with the completion inserted + val prefixBeforeWord = text.substring(0, wordStart) + val suffixAfterCaret = text.substring(caretPos) + val fullSuggestion = prefixBeforeWord + bestMatch + suffixAfterCaret + + // ghostTextForRendering needs to start with the full text so rendering code's + // ghostText.startsWith(text) check passes. It's: prefix + matched entity + val ghostTextForRendering = prefixBeforeWord + bestMatch + + // lastGhostText stores just the completion for comparison logic + lastGhostText = completionPart + // lastBestMatch stores the full entity for skip-search optimization + lastBestMatch = bestMatch + // setGhostText needs text that starts with user's input (rendering code expects ghostText.startsWith(userText)) + // Pass caretPos so rendering knows where to draw the ghost text (supports mid-text completions) + holder.setGhostText(ghostTextForRendering, caretPos) + holder.setFullGhostText(fullSuggestion) + + if (!project.isDisposed) { + project.messageBus.syncPublisher(GHOST_TEXT_TOPIC).onGhostTextChanged() + } + } else { + lastGhostText = "" + lastBestMatch = "" + holder.setGhostText("") + if (!project.isDisposed) { + project.messageBus.syncPublisher(GHOST_TEXT_TOPIC).onGhostTextChanged() + } + } + } + } else { + ApplicationManager.getApplication().invokeLater { + if (lastGhostText.isNotEmpty()) { + lastGhostText = "" + lastBestMatch = "" + holder.setGhostText("") + if (!project.isDisposed) { + project.messageBus.syncPublisher(GHOST_TEXT_TOPIC).onGhostTextChanged() + } + } + } + } + }, SWEEP_GHOST_TEXT_DELAY) + } + + override fun dispose() { + // Clean up all attached listeners + attachedListeners.keys.toList().forEach { holder -> + detachGhostTextFrom(holder) + } + alarm.dispose() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/IDEVersion.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/IDEVersion.kt new file mode 100644 index 0000000..16ab2cb --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/IDEVersion.kt @@ -0,0 +1,96 @@ +package com.oxidecode.data + +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ex.ApplicationInfoEx +import com.intellij.openapi.util.BuildNumber +import com.intellij.util.text.SemVer + +/** + * Represents an IDE version that can be compared with other versions. + * Supports semantic versioning comparison. + */ +class IDEVersion private constructor( + private val semVer: SemVer, +) : Comparable { + companion object { + private val semverRegex = Regex("""\d+(?:\.\d+){1,2}""") // grabs "2024.3" or "2024.3.6" from strings like "2024.3 EAP" + + private fun normalizeToSemVerString(version: String): String { + // Keep only numeric parts + val parts = version.split(Regex("""\D+""")).filter { it.isNotEmpty() } + return when (parts.size) { + 1 -> "${parts[0]}.0.0" + 2 -> "${parts[0]}.${parts[1]}.0" + else -> "${parts[0]}.${parts[1]}.${parts[2]}" + } + } + + /** Returns the current IDE's version */ + fun current(): IDEVersion { + val info = ApplicationInfo.getInstance() + val raw = info.fullVersion // "2024.3 EAP" / "2024.3.1.0" + val normalized = normalizeToSemVerString(raw) + val semVer = SemVer.parseFromText(normalized) ?: SemVer.parseFromText("0.0.0")!! + return IDEVersion(semVer) + } + + /** Creates an IDEVersion from a version string (e.g., "2024.3.6" or "2024.3") */ + fun fromString(version: String): IDEVersion { + val cleaned = semverRegex.find(version)?.value ?: version + val semVer = SemVer.parseFromText(cleaned) ?: SemVer.parseFromText("0.0.0")!! + return IDEVersion(semVer) + } + + /** Creates an IDEVersion from major.minor.patch components */ + fun fromComponents( + major: Int, + minor: Int, + patch: Int = 0, + ): IDEVersion { + val versionString = "$major.$minor.$patch" + val semVer = SemVer.parseFromText(versionString) ?: SemVer.parseFromText("0.0.0")!! + return IDEVersion(semVer) + } + + /** Extra helpers for current IDE version */ + fun isEap(): Boolean = ApplicationInfoEx.getInstanceEx().isEAP + + fun buildNumber(): BuildNumber = ApplicationInfo.getInstance().build // e.g., 243.x.y (== 2024.3) + + fun baseline(): Int = buildNumber().baselineVersion // e.g., 243 + } + + /** Returns the underlying SemVer object */ + fun semVer(): SemVer = semVer + + /** true if this version >= target version */ + fun isAtLeast(other: IDEVersion): Boolean = this >= other + + /** true if this version >= target (e.g., isAtLeast(2024,3,6)) */ + fun isAtLeast( + major: Int, + minor: Int, + patch: Int = 0, + ): Boolean = this >= fromComponents(major, minor, patch) + + /** String variant: isAtLeast("2024.3.6") */ + fun isAtLeast(version: String): Boolean = this >= fromString(version) + + /** Returns true if this version is newer than the other */ + fun isNewerThan(other: IDEVersion): Boolean = this > other + + /** Returns true if this version is older than the other */ + fun isOlderThan(other: IDEVersion): Boolean = this < other + + override fun compareTo(other: IDEVersion): Int = semVer.compareTo(other.semVer) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IDEVersion) return false + return semVer == other.semVer + } + + override fun hashCode(): Int = semVer.hashCode() + + override fun toString(): String = semVer.toString() +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/Models.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/Models.kt new file mode 100644 index 0000000..9f61fa9 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/Models.kt @@ -0,0 +1,720 @@ +package com.oxidecode.data + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import com.intellij.openapi.application.PermanentInstallationID +import com.oxidecode.utils.baseNameFromPathString +import com.oxidecode.utils.getDebugInfo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TodoItem( + val id: String, + val content: String, + val status: String = "pending", +) + +/** + * Represents a notification that the backend wants to show to the user. + * This allows the backend to send arbitrary notifications without overriding message content. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class BackendNotification( + val title: String, + val body: String, + /** One of: "information", "warning", "error". Defaults to "information" if not specified. */ + val type: String = "information", + /** Optional URL to open when user clicks an action button */ + val actionUrl: String? = null, + /** Optional label for the action button */ + val actionLabel: String? = null, + /** If true, stops the conversation after showing the notification */ + val stopConversation: Boolean = false, +) + +@Serializable +enum class MessageRole { + @JsonProperty("system") + @SerialName("system") + SYSTEM, + + @JsonProperty("user") + @SerialName("user") + USER, + + @JsonProperty("assistant") + @SerialName("assistant") + ASSISTANT, +} + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class CodeReplacement + @JsonCreator + constructor( + @get:JsonProperty("code_block_index") + @JsonProperty("code_block_index") + @SerialName("code_block_index") + val codeBlockIndex: Int, + @get:JsonProperty("file_path") + @JsonProperty("file_path") + @SerialName("file_path") + val filePath: String, + @get:JsonProperty("code_block_content") + @JsonProperty("code_block_content") + @SerialName("code_block_content") + val codeBlockContent: String, + @get:JsonProperty("diffs_to_apply") + @JsonProperty("diffs_to_apply") + @SerialName("diffs_to_apply") + var diffsToApply: Map = mapOf(), + @JsonProperty("apply_id") + @SerialName("apply_id") + val applyId: String? = null, + ) + +@Serializable +data class FileLocation( + @JsonProperty("file_path") + @SerialName("file_path") + val filePath: String, + @JsonProperty("line_number") + @SerialName("line_number") + val lineNumber: Int? = null, + @JsonProperty("is_directory") + @SerialName("is_directory") + val isDirectory: Boolean = false, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class CompletedToolCall( + @JsonProperty("tool_call_id") + @SerialName("tool_call_id") + val toolCallId: String, + @JsonProperty("tool_name") + @SerialName("tool_name") + val toolName: String, + @JsonProperty("result_string") + @SerialName("result_string") + val resultString: String, + @JsonProperty("status") + @SerialName("status") + val status: Boolean, + @JsonProperty("is_mcp") + @SerialName("is_mcp") + val isMcp: Boolean = false, + @JsonProperty("mcp_properties") + @SerialName("mcp_properties") + val mcpProperties: Map = mapOf(), + @JsonProperty("file_locations") + @SerialName("file_locations") + val fileLocations: List = emptyList(), + @JsonProperty("orig_file_contents") + @SerialName("orig_file_contents") + val origFileContents: Map? = null, + @JsonProperty("error_type") + @SerialName("error_type") + val errorType: String? = null, + @JsonProperty("notebook_edit_old_cell") + @SerialName("notebook_edit_old_cell") + val notebookEditOldCell: String? = null, + @JsonProperty("todo_state") + @SerialName("todo_state") + val todoState: List? = null, +) { + val isRejected: Boolean + get() = !status && resultString.startsWith("Rejected:") +} + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class ToolCall( + @JsonProperty("tool_call_id") + @SerialName("tool_call_id") + val toolCallId: String, + @JsonProperty("tool_name") + @SerialName("tool_name") + val toolName: String, + @JsonProperty("tool_parameters") + @SerialName("tool_parameters") + val toolParameters: Map = mapOf(), + @JsonProperty("raw_text") + @SerialName("raw_text") + val rawText: String, + @JsonProperty("is_mcp") + @SerialName("is_mcp") + val isMcp: Boolean = false, + @JsonProperty("mcp_properties") + @SerialName("mcp_properties") + val mcpProperties: Map = mapOf(), + @JsonProperty("fully_formed") + @SerialName("fully_formed") + val fullyFormed: Boolean = false, + @JsonProperty("thought_signature") + @SerialName("thought_signature") + val thoughtSignature: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class TokenUsage( + @JsonProperty("input_tokens") + @SerialName("input_tokens") + val inputTokens: Int = 0, + @JsonProperty("output_tokens") + @SerialName("output_tokens") + val outputTokens: Int = 0, + @JsonProperty("cache_read_tokens") + @SerialName("cache_read_tokens") + val cacheReadTokens: Int = 0, + @JsonProperty("cache_write_tokens") + @SerialName("cache_write_tokens") + val cacheWriteTokens: Int = 0, + @JsonProperty("model") + @SerialName("model") + val model: String = "", + @JsonProperty("max_tokens") + @SerialName("max_tokens") + val maxTokens: Int = 1, + @JsonProperty("cost_with_markup_cents") + @SerialName("cost_with_markup_cents") + val costWithMarkupCents: Double = 0.0, +) { + fun totalTokens(): Int = + inputTokens.coerceAtLeast(0) + outputTokens.coerceAtLeast(0) + + cacheReadTokens.coerceAtLeast(0) + cacheWriteTokens.coerceAtLeast(0) + + operator fun plus(other: TokenUsage): TokenUsage = + TokenUsage( + inputTokens = this.inputTokens + other.inputTokens, + outputTokens = this.outputTokens + other.outputTokens, + cacheReadTokens = this.cacheReadTokens + other.cacheReadTokens, + cacheWriteTokens = this.cacheWriteTokens + other.cacheWriteTokens, + model = this.model, + maxTokens = this.maxTokens, // Keep the same maxTokens from the first TokenUsage + costWithMarkupCents = this.costWithMarkupCents + other.costWithMarkupCents, + ) + + fun hasUsage(): Boolean = totalTokens() > 0 +} + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class Annotations( + var codeReplacements: MutableList = mutableListOf(), + var toolCalls: MutableList = mutableListOf(), + val completedToolCalls: MutableList = mutableListOf(), + val thinking: String = "", + val stopStreaming: String = "", + var actionPlan: String = "", + var cursorLineNumber: Int? = null, + var cursorLineContent: String? = null, + var currentFilePath: String? = null, + var filesToLastDiffs: Map? = null, + var mentionedFiles: MutableList? = null, + var tokenUsage: TokenUsage? = null, + @JsonProperty("completion_time") + @SerialName("completion_time") + var completionTime: Long? = null, + /** Notification from backend to show to the user without overriding message content */ + var notification: BackendNotification? = null, +) + +@Serializable +data class FullFileContentStore( + val name: String, + val relativePath: String, + val span: Pair? = null, + val codeSnippet: String? = null, // this will be the hash of the contents + val timestamp: Long? = null, + val isFromStringReplace: Boolean = false, + val isFromCreateFile: Boolean = false, + val conversationId: String? = null, +) { + val is_full_file: Boolean + get() = span == null +} + +@Serializable +data class AppliedCodeBlockRecord( + val id: String, + val messageIndex: Int, + val name: String, + val relativePath: String, + val contentHash: String? = null, // this will be the hash of the contents + val index: Int? = null, // index of codeblock in assistant response, user might not apply all blocks + val timestamp: Long? = null, +) + +fun List.distinctFullFileContentStores(): List = + distinctBy { + "${it.name}:${it.relativePath}:${it.span}:${it.codeSnippet}" + } + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class Message + @JsonCreator + constructor( + @JsonProperty("role") val role: MessageRole, + @JsonProperty("content") var content: String, + @JsonProperty("annotations") val annotations: Annotations? = null, + @JsonSetter(nulls = Nulls.AS_EMPTY) + @JsonProperty("mentionedFiles") + var mentionedFiles: List = emptyList(), + @JsonProperty("mentionedFilesStoredContents") + var mentionedFilesStoredContents: List? = null, + @JsonProperty("appliedCodeBlockRecords") + var appliedCodeBlockRecords: List? = null, + @JsonProperty("diffString") + var diffString: String? = null, + @JsonProperty("images") + var images: List = emptyList(), + ) + +@Serializable +data class Snippet( + val content: String, + val start: Int = 0, + val end: Int = 0, + val file_path: String = "", + var is_full_file: Boolean = false, + var score: Float = 0f, +) { + fun toFileInfo(): FileInfo { + val lines = content.lines() + return FileInfo( + name = baseNameFromPathString(file_path), + relativePath = file_path, + // very important that full span is set to null + span = if (start <= 1 && end >= content.lines().size) null else start to end, + codeSnippet = + if (is_full_file) { + null + } else { + lines + .slice((start - 1).coerceAtLeast(0)..(end - 1).coerceAtMost(lines.lastIndex)) + .joinToString("\n") + }, + score = score, + ) + } +} + +fun List.distinctSnippets(): List = + distinctBy { + if (it.is_full_file) { + "${it.file_path}:${it.content}" + } else { + "${it.file_path}:${it.content}:${it.start}:${it.end}" + } + } + +fun List.fullFileSnippets(): List = filter { it.is_full_file } + +@Serializable +abstract class BaseRequest { + @SerialName("debug_info") + val debugInfo: String = getDebugInfo() + + @SerialName("device_id") + val deviceId: String = PermanentInstallationID.get() +} + +@Serializable +data class UsageEvent( + @SerialName("event_type") + val eventType: String, + @SerialName("user_properties") + val userProperties: Map = emptyMap(), + @SerialName("event_properties") + val eventProperties: Map = emptyMap(), +) : BaseRequest() + +@Serializable +data class UserStoppingChatEvent( + @SerialName("unique_chat_id") + val uniqueChatId: String = "", + @SerialName("chat_type_for_telemetry") + val chatTypeForTelemetry: String = "chat", +) : BaseRequest() + +@Serializable +data class FileModification( + @SerialName("original_contents") + val originalContents: String, + @SerialName("contents") + val contents: String, +) + +@Serializable +data class FeedbackSubmission( + val feedback: String, + val messages: List = listOf(), + val lastMessage: Message?, + val snippets: List = listOf(), + val codeReplacements: List, + val metadata: Map = mapOf(), // sweep_rules, last_diff, etc. +) : BaseRequest() + +@Serializable +data class Skill( + val name: String, + val description: String, + @SerialName("front_matter") + val frontMatter: String, + val content: String, + @SerialName("absolute_path") + val absolutePath: String, +) : BaseRequest() + +@Serializable +data class ChatRequest( + val repo_name: String, + val branch: String? = null, + val messages: List = listOf(), + val main_snippets: List = listOf(), + val reference_repo_snippets: List = listOf(), + val modify_files_dict: Map = mapOf(), + val annotations: Map = mapOf(), + val current_open_file: String? = null, + val current_cursor_offset: Int? = null, + val telemetry_source: String = "jetbrains", + val sweep_rules: String = "", + val last_diff: String = "", + val model_to_use: String? = null, + val privacy_mode_enabled: Boolean = false, + val chat_mode: String = "Agent", + val is_followup_to_tool_call: Boolean = false, + val use_multi_tool_calling: Boolean = false, + val give_agent_edit_tools: Boolean = true, + val allow_thinking: Boolean = false, + val allow_prompt_crunching: Boolean = false, + val allow_bash: Boolean = false, + val mcp_tools: List> = emptyList(), + val allow_powershell: Boolean = true, + val is_planning_mode: Boolean = false, + val action_plan: String = "", + val use_new_read_file_tool: Boolean = true, + val use_new_search_tool: Boolean = true, + val working_directory: String = "", + val stream_tool_calls: Boolean = true, + val allow_notebook_edit: Boolean = true, + val include_token_usage: Boolean = true, + val allow_multi_str_replace: Boolean = true, + val allow_todo_write: Boolean = true, + val unique_chat_id: String = "", + val conversation_id: String = "", + val enable_web_search: Boolean = false, + val enable_web_fetch: Boolean = false, + val byok_api_key: String = "", + val skills: List = emptyList(), + val detected_shell_path: String = "", +) : BaseRequest() + +@Serializable +data class RelevanceRequest( + val repo_name: String, + val query: String, + val snippets: List = listOf(), + val index: Int, +) : BaseRequest() + +@Serializable +data class SearchRequest( + val repo_name: String, + val branch: String? = null, + val query: String, + val messages: List = listOf(), + val annotations: Map = mapOf(), + val existing_snippets: List = listOf(), + val current_open_file: String? = null, + val current_conversation: String = "", + val open_files: List = emptyList(), +) : BaseRequest() + +@Serializable +data class FastApplyRequest( + val repo_name: String, + val branch: String? = null, + val rewritten_code: String, + val stream: Boolean = true, + val modify_files_dict: Map = mapOf(), + val messages: List = listOf(), + val telemetry_source: String = "jetbrains", + val privacy_mode_enabled: Boolean = false, +) : BaseRequest() + +@Serializable +data class AutocompleteRequest( + val repo_name: String, + val branch: String? = null, + val parent_block: String, + val file_path: String, + val file_contents: String, + val snippets: List, + val telemetry_source: String = "jetbrains", + val last_completion_accepted: Boolean? = null, + val last_completion_time_delta_ms: Long? = null, +) : BaseRequest() + +@Serializable +data class AutocompleteResponse( + val current_block: String, + val confidence: Float, + val record_id: String, +) + +/** + * Used for storing snippets of code. + * Span and codeSnippet are not null only for code snippets are null for full files + * Special case: for code snippets with no source information the name will be + * SweepCustomGeneralTextSnippet- + * Where the source can be something like TerminalOutput or ConsoleOutput or CopyPaste etc. + * The span will be null and the codeSnippet will store the actual contents the relativepath will be to a tmp file or "" + */ +@Serializable +data class FileInfo( + val name: String, + val relativePath: String, + val span: Pair? = null, + val codeSnippet: String? = null, + val score: Float? = null, + val fileText: String? = null, + val is_from_string_replace: Boolean = false, +) { + val is_full_file: Boolean + get() = span == null + + fun toSnippet(): Snippet = + Snippet( + content = codeSnippet ?: "", + start = span?.first ?: 0, + end = span?.second ?: 0, + file_path = relativePath, + is_full_file = is_full_file, + score = score ?: 0f, + ) +} + +fun List.distinctFileInfos(): List = + distinctBy { + "${it.name}:${it.relativePath}:${it.span}:${it.codeSnippet}" + } + +fun MutableList.removeFileInfo( + fileInfo: FileInfo, + generalTextSnippet: Boolean = false, +): Boolean { + val identifier = + if (generalTextSnippet) { + "${fileInfo.relativePath}:${fileInfo.span}:${fileInfo.codeSnippet}" + } else { + "${fileInfo.name}:${fileInfo.relativePath}:${fileInfo.span}:${fileInfo.codeSnippet}" + } + + return removeIf { + val itemIdentifier = + if (generalTextSnippet) { + "${it.relativePath}:${it.span}:${it.codeSnippet}" + } else { + "${it.name}:${it.relativePath}:${it.span}:${it.codeSnippet}" + } + + itemIdentifier == identifier + } +} + +@Serializable +data class ConversationNameRequest( + val message: String, + val context: String = "", +) : BaseRequest() + +@Serializable +data class CommitMessageRequest( + val context: String, + val previous_commits: String, + val branch: String, + val commit_template: String? = null, +) : BaseRequest() + +@Serializable +enum class ApplyStatusLabel { + @JsonProperty("user_rejected") + @SerialName("user_rejected") + USER_REJECTED, + + @JsonProperty("corrupted_patch") + @SerialName("corrupted_patch") + CORRUPTED_PATCH, + + @JsonProperty("no_changes_found") + @SerialName("no_changes_found") + NO_CHANGES_FOUND, + + @JsonProperty("user_accepted") + @SerialName("user_accepted") + USER_ACCEPTED, +} + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class ApplyStatusUpdate + @JsonCreator + constructor( + @JsonProperty("filePath") val filePath: String, + @JsonProperty("id") val id: String, + @JsonProperty("label") val label: ApplyStatusLabel, + ) + +@Serializable +enum class AutocompleteStatusLabel { + @JsonProperty("rejected") + @SerialName("rejected") + REJECTED, + + @JsonProperty("accepted") + @SerialName("accepted") + ACCEPTED, +} + +@Serializable +data class AutocompleteStatusUpdate( + val id: String, + val label: AutocompleteStatusLabel, +) : BaseRequest() + +@Serializable +data class GenerateCommandRequest( + val query: String, + val snippets: List = listOf(), +) : BaseRequest() + +@Serializable +data class SweepErrorRequest( + val error: HashMap, +) : BaseRequest() + +@Deprecated("This class is deprecated and will be removed in a future version") +@Serializable +data class FileSyncRequest( + val repo_name: String, + val files: Map, + @SerialName("timestamp") + val timestamp: Long, + @SerialName("is_last") + val isLast: Boolean, + @SerialName("is_full_sync") + val isFullSync: Boolean, + @SerialName("chunk_index") + val chunkIndex: Int, +) : BaseRequest() + +@Deprecated("This class is deprecated and will be removed in a future version") +@Serializable +data class FileSyncResponse( + val status: String, + val file_count: Int, +) + +@Serializable +data class AllowedModelsV2Request( + val repo_name: String, +) : BaseRequest() + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class AllowedModelsV2Response( + val models: Map, + val default_model: Map, + val favorite_models: Map = emptyMap(), + val favorite_version: Int = 0, +) + +@Serializable +data class CmdKRequest( + val instruction: String, + val selected_code: String, + val file_content: String, + val stream: Boolean = true, + val model_to_use: String? = null, + val full_file: Boolean = false, + val file_path: String? = null, + val conversation_history: List>? = null, + val isFollowup: Boolean = false, + val selection_start_line: Int? = null, + val selection_end_line: Int? = null, +) : BaseRequest() + +@Serializable +data class Image( + val file_type: String, + val url: String? = null, + val base64: String? = null, + val filePath: String? = null, +) + +@Serializable +data class StartupLogRequest( + val client_ip: String?, + val latency: Long, +) : BaseRequest() + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class BYOKProviderInfo( + @JsonProperty("display_name") + @SerialName("display_name") + val displayName: String, + @JsonProperty("eligible_models") + @SerialName("eligible_models") + val eligibleModels: List, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class BYOKModelsResponse( + val providers: Map, +) { + companion object { + fun fromMap(map: Map>): BYOKModelsResponse { + val providers = + map.mapValues { (_, value) -> + BYOKProviderInfo( + displayName = value["display_name"] as? String ?: "", + eligibleModels = (value["eligible_models"] as? List<*>)?.filterIsInstance() ?: emptyList(), + ) + } + return BYOKModelsResponse(providers) + } + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class PresetMcpServer( + val name: String, + val description: String, + val jsonString: String, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class PresetMcpServersResponse( + val servers: List, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class UsernameResponse( + val username: String, + @SerialName("privacy_mode_enabled") + val privacyModeEnabled: Boolean = false, // Default to false if backend doesn't provide +) diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/RecentFilesBase.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/RecentFilesBase.kt new file mode 100644 index 0000000..4464c14 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/RecentFilesBase.kt @@ -0,0 +1,37 @@ +package com.oxidecode.data + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.util.SlowOperations + +abstract class RecentFilesBase( + protected val project: Project, +) : Disposable { + protected val recentFiles = ArrayDeque() + + protected fun loadFromDisk() { + val props = PropertiesComponent.getInstance(project) + val serialized = props.getValue(this.javaClass.name) ?: return + synchronized(recentFiles) { + recentFiles.clear() + serialized.split(";").forEach { path -> + if (path.isNotEmpty()) { + recentFiles.addLast(path) + } + } + } + } + + protected fun persistToDisk() { + SlowOperations.assertSlowOperationsAreAllowed() + if (project.isDisposed) return + val props = PropertiesComponent.getInstance(project) + // Create a defensive copy to avoid ConcurrentModificationException + val filesCopy = synchronized(recentFiles) { recentFiles.toList() } + val serialized = filesCopy.joinToString(";") + props.setValue(this.javaClass.name, serialized) + } + + fun getFiles(): List = synchronized(recentFiles) { recentFiles.toList() } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/RecentlyUsedFiles.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/RecentlyUsedFiles.kt new file mode 100644 index 0000000..008b786 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/RecentlyUsedFiles.kt @@ -0,0 +1,64 @@ +package com.oxidecode.data + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.oxidecode.listener.FileChangedAction +import com.oxidecode.listener.SelectedFileChangeListener +import com.oxidecode.services.OxideCodeNonProjectFilesService +import com.oxidecode.services.OxideCodeProjectService +import com.oxidecode.utils.getCurrentSelectedFile +import com.oxidecode.utils.relativePath + +@Service(Service.Level.PROJECT) +class RecentlyUsedFiles( + project: Project, +) : RecentFilesBase(project), + Disposable { + companion object { + const val MAX_SIZE = 10 + + fun getInstance(project: Project): RecentlyUsedFiles = project.getService(RecentlyUsedFiles::class.java) + } + + private val selectedFileChangeListener = SelectedFileChangeListener.create(project, this) + + init { + Disposer.register(OxideCodeProjectService.getInstance(project), this) + loadFromDisk() + relativePath(project, getCurrentSelectedFile(project))?.also { + recentFiles.remove(it) + recentFiles.addFirst(it) + } + selectedFileChangeListener.addOnFileChangedAction( + FileChangedAction("RecentlyUsedFiles") { newFile, _ -> + if (newFile == null) { + return@FileChangedAction + } + val currentFile = relativePath(project, newFile) + if (currentFile != null) { + recentFiles.remove(currentFile) + recentFiles.addFirst(currentFile) + if (recentFiles.size > MAX_SIZE) { + recentFiles.removeLast() + } + ApplicationManager.getApplication().executeOnPooledThread { + persistToDisk() + } + } else { + val filePath = newFile.path + val notDirectory = !newFile.isDirectory + if (notDirectory) { + OxideCodeNonProjectFilesService.getInstance(project).addAllowedFile(filePath) + } + } + }, + ) + } + + override fun dispose() { + selectedFileChangeListener.removeOnFileChangedAction("RecentlyUsedFiles") + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/SelectedSnippet.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/SelectedSnippet.kt new file mode 100644 index 0000000..5d4d49b --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/SelectedSnippet.kt @@ -0,0 +1,37 @@ +package com.oxidecode.data + +import java.util.regex.Pattern +import kotlin.math.max + +class SelectedSnippet( + fileName: String, + startLine: Int, // 1-based (to be consistent with the backend) + endLine: Int, // 1-based (to be consistent with the backend) + val isPending: Boolean = false, +) { + private val selectedSnippet = Triple(fileName, startLine, endLine) + + val first: String get() = selectedSnippet.first + val second: Int get() = selectedSnippet.second + val third: Int get() = selectedSnippet.third + + val denotation: String + get() = if (isPending) "Selection" else selectedSnippet.run { "$first ($second-$third)" } + + companion object { + val selectedSnippetMentionPattern: Pattern = Pattern.compile("(.*) [(]([0-9]+)-([0-9]+)[)]") + + fun fromDenotation(str: String): SelectedSnippet { + selectedSnippetMentionPattern.matcher(str).let { + if (!it.matches()) error("Snippet did not match pattern: $str") + return SelectedSnippet(it.group(1), max(it.group(2).toInt(), 1), it.group(3).toInt()) + } + } + } + + override fun equals(other: Any?): Boolean = other is SelectedSnippet && selectedSnippet == other.selectedSnippet + + override fun hashCode(): Int = selectedSnippet.hashCode() + + override fun toString(): String = denotation +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/listener/FileActions.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/listener/FileActions.kt new file mode 100644 index 0000000..43129f6 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/listener/FileActions.kt @@ -0,0 +1,13 @@ +package com.oxidecode.listener + +import com.intellij.openapi.vfs.VirtualFile + +data class FileChangedAction( + val identifier: String, + val onFileChanged: (VirtualFile?, VirtualFile?) -> Unit, +) + +data class FileEditedAction( + val identifier: String, + val onFileEdited: (VirtualFile?) -> Unit, +) diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/listener/SelectedFileChangeListener.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/listener/SelectedFileChangeListener.kt new file mode 100644 index 0000000..8ea4f7f --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/listener/SelectedFileChangeListener.kt @@ -0,0 +1,59 @@ +package com.oxidecode.listener + +import com.intellij.openapi.Disposable +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import com.oxidecode.utils.BLOCKED_URL_PREFIXES + +/** + * Instead of being a global singleton via ProjectMap, this listener is now an ordinary class. + * A factory method (create) is provided so that clients can easily create an instance that is + * automatically subscribed to the project's file editor events. + */ +class SelectedFileChangeListener( + private val project: Project, +) : FileEditorManagerListener, + Disposable { + private val onFileChangedActions = mutableMapOf Unit>() + + fun addOnFileChangedAction(fileChangedAction: FileChangedAction) { + onFileChangedActions[fileChangedAction.identifier] = { newFile, oldFile -> + // Skip blocked URL prefixes + val isBlocked = + newFile?.url?.let { url -> + BLOCKED_URL_PREFIXES.any { url.startsWith(it) } + } ?: false + if (!isBlocked) { + fileChangedAction.onFileChanged(newFile, oldFile) + } + } + } + + override fun selectionChanged(event: FileEditorManagerEvent) { + onFileChangedActions.forEach { (_, onFileChanged) -> onFileChanged(event.newFile, event.oldFile) } + } + + fun removeOnFileChangedAction(identifier: String) { + onFileChangedActions.remove(identifier) + } + + companion object { + fun create( + project: Project, + parentDisposable: Disposable, + ): SelectedFileChangeListener { + val listener = SelectedFileChangeListener(project) + FileEditorManager.getInstance(project).addFileEditorManagerListener(listener) + Disposer.register(parentDisposable, listener) + return listener + } + } + + override fun dispose() { + FileEditorManager.getInstance(project).removeFileEditorManagerListener(this) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/AutocompleteIpResolverService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/AutocompleteIpResolverService.kt new file mode 100644 index 0000000..aa85f73 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/AutocompleteIpResolverService.kt @@ -0,0 +1,250 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.PermanentInstallationID +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.oxidecode.autocomplete.edit.NextEditAutocompleteRequest +import com.oxidecode.autocomplete.edit.NextEditAutocompleteResponse +import com.oxidecode.settings.OxideCodeConfig +import com.oxidecode.settings.OxideCodeSettings +import com.oxidecode.utils.CompressionUtils +import com.oxidecode.utils.encodeString +import com.oxidecode.utils.getCurrentSweepPluginVersion +import com.oxidecode.utils.getDebugInfo +import com.oxidecode.utils.defaultJson +import com.oxidecode.utils.raiseForStatus +import com.oxidecode.utils.streamJson +import kotlinx.coroutines.* +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.coroutines.future.await +import java.net.InetAddress +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.concurrent.atomic.AtomicLong + +/** + * Service that periodically resolves the IP address of autocomplete.sweep.dev + * to keep DNS cache warm while using HTTPS with the domain name directly. + */ +@Service(Service.Level.PROJECT) +class AutocompleteIpResolverService( + private val project: Project, +) : Disposable { + companion object { + private val logger = Logger.getInstance(AutocompleteIpResolverService::class.java) + + fun getInstance(project: Project): AutocompleteIpResolverService = project.getService(AutocompleteIpResolverService::class.java) + + private const val HOSTNAME = "autocomplete.sweep.dev" + private const val RESOLUTION_INTERVAL_MS = 15_000L + private const val HEALTH_CHECK_INTERVAL_MS = 25_000L // Just under 30 seconds + private const val READ_TIMEOUT_MS = 10_000L + private const val USER_ACTIVITY_TIMEOUT_MS = 15 * 60 * 1000L // 15 minutes + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val lastLatencyMs = AtomicLong(-1L) // -1 indicates no measurement yet + private val lastUserActionTimestamp = AtomicLong(System.currentTimeMillis()) // Initialize with current time + private var resolutionJob: Job? = null + private var healthCheckJob: Job? = null + + // HTTP client with connection pooling and keep-alive + private val httpClient = + HttpClient + .newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(3)) + .build() + + /** + * Gets the shared HttpClient instance for connection pooling. + * This allows other services to use the same connection pool. + */ + fun getSharedHttpClient(): HttpClient = httpClient + + /** + * Executes a next edit autocomplete request. + * This centralizes the entire HTTP request flow in the DNS resolver service. + */ + @RequiresBackgroundThread + suspend fun fetchNextEditAutocomplete(request: NextEditAutocompleteRequest): NextEditAutocompleteResponse? = + try { + val postData = encodeString(request, NextEditAutocompleteRequest.serializer()) + val postDataBytes = postData.toByteArray(Charsets.UTF_8) + + // Try to compress the request data + val (finalData, useCompression) = + if (CompressionUtils.isBrotliAvailable()) { + val compressedData = CompressionUtils.compress(postDataBytes, CompressionUtils.CompressionType.BROTLI) + if (compressedData.size < postDataBytes.size) { + val compressionRatio = CompressionUtils.calculateCompressionRatio(postDataBytes.size, compressedData.size) + logger.info( + "Request compressed: ${postDataBytes.size} -> ${compressedData.size} bytes (${String.format( + "%.1f", + compressionRatio, + )}% reduction)", + ) + Pair(compressedData, true) + } else { + logger.info("Compression not beneficial, sending uncompressed") + Pair(postDataBytes, false) + } + } else { + logger.info("Brotli not available, sending uncompressed") + Pair(postDataBytes, false) + } + + val httpRequestBuilder = + HttpRequest + .newBuilder() + .uri(URI.create("${getBaseUrl()}/backend/next_edit_autocomplete")) + .timeout(Duration.ofMillis(READ_TIMEOUT_MS)) + .header("Content-Type", "application/json") + + if (useCompression) { + httpRequestBuilder.header("Content-Encoding", CompressionUtils.CompressionType.BROTLI.encoding) + } + + val httpRequest = httpRequestBuilder.POST(HttpRequest.BodyPublishers.ofByteArray(finalData)).build() + + val response = + httpClient + .sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream()) + .await() + .raiseForStatus() + + var result: NextEditAutocompleteResponse? = null + + response.streamJson().collect { + result = it + } + + result + } catch (e: Exception) { + logger.warn("Error fetching next edit autocomplete: ${e.message}") + throw e + } + + init { + startPeriodicResolution() + startPeriodicHealthCheck() + } + + fun getBaseUrl(): String { + return OxideCodeSettings.getInstance().baseUrl + } + + /** + * Gets the last measured latency in milliseconds. + * Returns -1 if no measurement has been taken yet. + */ + fun getLastLatencyMs(): Long = lastLatencyMs.get() + + /** + * Updates the timestamp of the last user action. + * Call this whenever the user performs any action (typing, clicking, etc.). + */ + fun updateLastUserActionTimestamp() { + lastUserActionTimestamp.set(System.currentTimeMillis()) + } + + /** + * Checks if there was user activity within the last 10 minutes. + */ + private fun hasRecentUserActivity(): Boolean { + val currentTime = System.currentTimeMillis() + val lastActivity = lastUserActionTimestamp.get() + return (currentTime - lastActivity) <= USER_ACTIVITY_TIMEOUT_MS + } + + private fun startPeriodicResolution() { + resolutionJob = + scope.launch { + // Initial resolution + resolveIpAddress() + + // Periodic resolution every 15 seconds, but only if user was active in last 10 minutes + while (isActive) { + delay(RESOLUTION_INTERVAL_MS) + if (hasRecentUserActivity()) { + resolveIpAddress() + } + } + } + } + + private fun startPeriodicHealthCheck() { + healthCheckJob = + scope.launch { + // Initial health check + performHealthCheck() + + // Periodic health check every 25 seconds, but only if user was active in last 10 minutes + while (isActive) { + delay(HEALTH_CHECK_INTERVAL_MS) + if (hasRecentUserActivity()) { + performHealthCheck() + } + } + } + } + + private suspend fun resolveIpAddress() { + try { + withContext(Dispatchers.IO) { + // Just resolve the hostname to keep DNS cache warm + // We don't use the IP addresses, just let the OS cache them + InetAddress.getAllByName(HOSTNAME) + } + } catch (e: Exception) { + logger.warn("Failed to resolve $HOSTNAME: ${e.message}") + } + } + + private suspend fun performHealthCheck() { + if (OxideCodeConfig.getInstance(project).isAutocompleteLocalMode()) return + try { + withContext(Dispatchers.IO) { + val baseUrl = getBaseUrl() + val startTime = System.currentTimeMillis() + + val request = + HttpRequest + .newBuilder() + .uri(URI.create(baseUrl)) + .timeout(Duration.ofMillis(READ_TIMEOUT_MS)) + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()) + val endTime = System.currentTimeMillis() + val latency = endTime - startTime + + if (response.statusCode() in 200..299) { + lastLatencyMs.set(latency) +// println("AutocompleteIpResolverService: Health check to $baseUrl successful, latency: ${latency}ms") + } else { + logger.warn("Health check to $baseUrl failed with response code: ${response.statusCode()}") + } + } + } catch (e: Exception) { + logger.warn("Health check failed: ${e.message}") + // Keep the last latency value on failure + } + } + + override fun dispose() { + resolutionJob?.cancel() + healthCheckJob?.cancel() + scope.cancel() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/ClipboardTrackingService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/ClipboardTrackingService.kt new file mode 100644 index 0000000..6c2932a --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/ClipboardTrackingService.kt @@ -0,0 +1,113 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import java.awt.datatransfer.DataFlavor + +/** + * Data class to hold clipboard content with timestamp + */ +data class ClipboardEntry( + val content: String, + val timestamp: Long = System.currentTimeMillis(), +) { + fun getDuration(): Long = System.currentTimeMillis() - timestamp +} + +/** + * Service that tracks clipboard changes and provides timestamped clipboard history + */ +@Service(Service.Level.PROJECT) +class ClipboardTrackingService( + private val project: Project, +) : Disposable { + companion object { + private val logger = Logger.getInstance(ClipboardTrackingService::class.java) + + fun getInstance(project: Project): ClipboardTrackingService = project.getService(ClipboardTrackingService::class.java) + } + + private var contentChangedListener: CopyPasteManager.ContentChangedListener? = null + + private var lastClipboardContent: String? = null + private var lastClipboardEntry: ClipboardEntry? = null + + init { + // Defer all clipboard access until UI is ready and we're on the EDT + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + startClipboardTracking() + } + } + + /** + * Gets the current clipboard content with timestamp + */ + fun getCurrentClipboardEntry(): ClipboardEntry? = lastClipboardEntry + + private fun startClipboardTracking() { + val manager = CopyPasteManager.getInstance() + + // Listen for clipboard changes via platform API (thread-safe, EDT-aware) + contentChangedListener = + CopyPasteManager.ContentChangedListener { _, newTransferable -> + try { + val text = + if (newTransferable != null && newTransferable.isDataFlavorSupported(DataFlavor.stringFlavor)) { + newTransferable.getTransferData(DataFlavor.stringFlavor) as? String + } else { + manager.getContents(DataFlavor.stringFlavor) + } + + if (text != null && text != lastClipboardContent) { + lastClipboardEntry = ClipboardEntry(text) + lastClipboardContent = text + } + } catch (_: Exception) { + // ignore non-text or transient clipboard errors + } + } + + // Use SweepProjectService as parent disposable per plugin guidelines + contentChangedListener?.let { listener -> + manager.addContentChangedListener(listener, OxideCodeProjectService.getInstance(project)) + } + + // Initial read on EDT after UI is ready + checkClipboardChange() + + lastClipboardEntry = lastClipboardEntry?.copy() + } + + private fun checkClipboardChange() { + try { + val currentContent = getClipboardContents() + + // Only track if content has changed + if (currentContent != null && currentContent != lastClipboardContent) { + lastClipboardEntry = ClipboardEntry(currentContent) + lastClipboardContent = currentContent + } + } catch (e: Exception) { + logger.warn("Error checking clipboard: ${e.message}") + } + } + + private fun getClipboardContents(): String? = + try { + CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor) + } catch (_: Exception) { + null + } + + override fun dispose() { + contentChangedListener?.let { + CopyPasteManager.getInstance().removeContentChangedListener(it) + } + contentChangedListener = null + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/CodeEntityExtractor.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/CodeEntityExtractor.kt new file mode 100644 index 0000000..159f5ab --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/CodeEntityExtractor.kt @@ -0,0 +1,412 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.event.VisibleAreaListener +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiNamedElement +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.util.Alarm +import java.awt.Point +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +/** + * Service that extracts entity names (classes, functions, variables, parameters, etc.) + * from the currently focused code file for ghost text suggestions. + * + * Uses a two-tier priority system: + * - Tier 1: Entities visible in the current viewport (recomputed on scroll with debounce) + * - Tier 2: Entities from the current file that are outside the viewport + * + * Only considers symbols from the current open file. + * + * Uses PsiNamedElement traversal for language-agnostic extraction, + * which automatically works for any language supported by IntelliJ. + */ +@Service(Service.Level.PROJECT) +class CodeEntityExtractor( + private val project: Project, +) : Disposable { + companion object { + fun getInstance(project: Project): CodeEntityExtractor = project.getService(CodeEntityExtractor::class.java) + + private const val MIN_ENTITY_NAME_LENGTH = 5 + private const val SCROLL_DEBOUNCE_MS = 1000L + } + + // Use SWING_THREAD since we need EDT for scrollingModel.visibleArea + // This avoids dangerous invokeAndWait calls from pooled threads + private val scrollDebounceAlarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) + + // Two-tier entity storage with thread-safe access + private val entityLock = ReentrantReadWriteLock() + private var currentFileViewportEntities: List = emptyList() + private var currentFileNonViewportEntities: List = emptyList() + private var currentFilePath: String? = null + + // Scroll listener lifecycle management + private var currentScrollListener: VisibleAreaListener? = null + private var currentEditor: Editor? = null + + /** + * Get all entity names with priority ordering. + * Tier 1 (current file viewport) comes first, then Tier 2 (current file non-viewport), + * then all currently open file names (without extensions). + * Uses cached entities - call refreshEntities() to update. + */ + fun getEntityNames(): List { + entityLock.read { + val combined = LinkedHashSet() + combined.addAll(currentFileViewportEntities) + combined.addAll(currentFileNonViewportEntities) + + // Add all currently open file names (without extensions) + val openFiles = FileEditorManager.getInstance(project).openFiles + openFiles.forEach { file -> + val fileName = file.name.substringBeforeLast('.') + if (fileName.length >= MIN_ENTITY_NAME_LENGTH) { + combined.add(fileName) + } + } + + return combined.toList() + } + } + + /** + * Force a full recomputation of all entities for the current file. + * Called when opening a file or when entities need to be refreshed. + * Returns the current file path if entities were refreshed, null otherwise. + * + * Can be called from any thread. EDT-required data is fetched safely. + */ + fun refreshEntities(): String? { + // Cancel pending scroll updates + scrollDebounceAlarm.cancelAllRequests() + + // Get visible range and current file - requires EDT for scrollingModel.visibleArea + // IMPORTANT: No locks are held when calling invokeAndWait, so no deadlock risk + var visibleRange: TextRange? = null + var virtualFile: VirtualFile? = null + var editorRef: Editor? = null + + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) { + // Already on EDT, get data directly + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return null + editorRef = editor + virtualFile = editor.virtualFile + visibleRange = getVisibleTextRange(editor) + } else { + // On background thread, fetch EDT-required data via invokeAndWait + // Safe because we hold no locks at this point + app.invokeAndWait { + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return@invokeAndWait + editorRef = editor + virtualFile = editor.virtualFile + visibleRange = getVisibleTextRange(editor) + } + } + + val file = virtualFile ?: return null + val range = visibleRange ?: return null + val editor = editorRef ?: return null + + return try { + // Extract entities in ReadAction, store results outside the lock + // ReadAction can run on any thread + var viewportEntities: List = emptyList() + var secondary: List = emptyList() + + ReadAction.run { + val psiManager = PsiManager.getInstance(project) + val psiFile = psiManager.findFile(file) ?: return@run + + // Extract Tier 1: current file viewport entities + viewportEntities = extractEntityNamesInRange(psiFile, range) + + // Extract Tier 2: current file non-viewport entities + secondary = extractNonViewportEntities(psiFile, range) + } + + // Write lock OUTSIDE ReadAction to avoid potential lock-order issues + entityLock.write { + currentFileViewportEntities = viewportEntities + currentFileNonViewportEntities = secondary + currentFilePath = file.path + } + + // Register scroll listener for the editor (must be done on EDT) + if (app.isDispatchThread) { + registerScrollListener(editor) + } else { + app.invokeLater { registerScrollListener(editor) } + } + + file.path + } catch (e: Exception) { + null + } + } + + /** + * Extract entities from current file that are OUTSIDE the viewport (Tier 2). + * Must be called within a ReadAction. + * + * Note: We only collect PsiNamedElement definitions here (not references) for performance. + * Reference collection is done only for viewport entities where it matters most. + */ + private fun extractNonViewportEntities( + psiFile: PsiFile, + viewportRange: TextRange, + ): List { + val document = psiFile.viewProvider.document ?: return emptyList() + val fileLength = document.textLength + if (fileLength == 0) return emptyList() + + // Create ranges for before and after viewport + val beforeRange = + if (viewportRange.startOffset > 0) { + TextRange(0, viewportRange.startOffset) + } else { + null + } + + val afterRange = + if (viewportRange.endOffset < fileLength) { + TextRange(viewportRange.endOffset, fileLength) + } else { + null + } + + fun isInNonViewportRange(elementRange: TextRange): Boolean = + (beforeRange?.intersects(elementRange) == true) || + (afterRange?.intersects(elementRange) == true) + + // Only collect named element definitions (skip reference collection for performance) + val allNames = + PsiTreeUtil + .collectElementsOfType(psiFile, PsiNamedElement::class.java) + .filter { element -> + val elementRange = element.textRange ?: return@filter false + isInNonViewportRange(elementRange) + }.mapNotNull { it.name } + .filter { isValidEntityName(it) } + + // Sort by frequency for Tier 2 + val frequencyMap = allNames.groupingBy { it }.eachCount() + return frequencyMap.keys + .sortedWith(compareByDescending { frequencyMap[it] ?: 0 }.thenBy { it.lowercase() }) + } + + /** + * Register scroll listener to update viewport entities on scroll (debounced). + */ + private fun registerScrollListener(editor: Editor) { + // Remove previous listener if exists + currentScrollListener?.let { listener -> + currentEditor?.scrollingModel?.removeVisibleAreaListener(listener) + } + + // Create and register new listener + val listener = VisibleAreaListener { scheduleViewportEntityUpdate(editor) } + editor.scrollingModel.addVisibleAreaListener(listener) + + currentScrollListener = listener + currentEditor = editor + } + + /** + * Schedule a debounced update of viewport entities after scrolling. + */ + private fun scheduleViewportEntityUpdate(editor: Editor) { + scrollDebounceAlarm.cancelAllRequests() + scrollDebounceAlarm.addRequest({ + updateCurrentFileViewportEntities(editor) + }, SCROLL_DEBOUNCE_MS) + } + + /** + * Update only the current file viewport entities (Tier 1). + * Called after scroll debounce on EDT - secondary entities remain cached. + */ + private fun updateCurrentFileViewportEntities(editor: Editor) { + // Check if editor was disposed during debounce delay (e.g., file was closed) + if (editor.isDisposed) return + + // We're on EDT (called via SWING_THREAD Alarm), so we can access visibleArea directly + val file = editor.virtualFile ?: return + val range = getVisibleTextRange(editor) ?: return + + try { + // Extract entities in ReadAction, store result outside the lock + val newEntities = + ReadAction.compute, RuntimeException> { + val psiFile = + PsiManager.getInstance(project).findFile(file) + ?: return@compute emptyList() + extractEntityNamesInRange(psiFile, range) + } + + // Write lock OUTSIDE ReadAction + entityLock.write { + currentFileViewportEntities = newEntities + // Note: currentFileNonViewportEntities remains unchanged + } + } catch (e: Exception) { + // Ignore errors during scroll updates + } + } + + /** + * Get the text range corresponding to the visible viewport in the editor. + * Returns null if the visible area cannot be determined or if the editor is disposed. + */ + private fun getVisibleTextRange(editor: Editor): TextRange? { + // Check disposal before any editor operations to avoid race conditions + // (editor could be disposed between caller's check and this method's execution) + if (editor.isDisposed) return null + + val visibleArea = editor.scrollingModel.visibleArea + if (visibleArea.height == 0 || visibleArea.width == 0) return null + + val document = editor.document + val lineCount = document.lineCount + if (lineCount == 0) return null + + // Convert visible area coordinates to logical line numbers + val firstVisibleLine = maxOf(editor.xyToLogicalPosition(Point(0, visibleArea.y)).line, 0) + val lastVisibleLine = + maxOf( + minOf( + editor.xyToLogicalPosition(Point(0, visibleArea.y + visibleArea.height)).line + 1, + lineCount - 1, + ), + firstVisibleLine, // Ensure lastVisibleLine is never less than firstVisibleLine + ) + + // Convert line numbers to document offsets + val startOffset = document.getLineStartOffset(firstVisibleLine) + val endOffset = document.getLineEndOffset(lastVisibleLine) + + // Guard against invalid range (can happen during document modifications or edge scroll positions) + if (startOffset > endOffset) return null + + return TextRange(startOffset, endOffset) + } + + /** + * Extract entity names from PSI elements within the specified text range. + * This works for any language and includes all named elements: classes, functions, + * variables, parameters, etc. + * + * Returns entities sorted by frequency (descending), with alphabetical as tiebreaker. + * This implements a simple Bayesian prior: P(entity) ∝ frequency in visible range. + */ + private fun extractEntityNamesInRange( + psiFile: PsiFile, + range: TextRange, + ): List { + fun isInRange(elementRange: TextRange): Boolean = range.intersects(elementRange) + + // Tier 1: Named element definitions (classes, functions, variables, etc.) + val namedElementNames = + PsiTreeUtil + .collectElementsOfType(psiFile, PsiNamedElement::class.java) + .filter { element -> + val elementRange = element.textRange + elementRange != null && isInRange(elementRange) + }.mapNotNull { it.name } + .filter { isValidEntityName(it) } + + // Tier 2: Reference usages (imports, variable references, etc.) + val referenceNames = + collectReferenceNames(psiFile) { elementRange -> + isInRange(elementRange) + } + + val allNames = namedElementNames + referenceNames + + // Count frequency of each entity name + val frequencyMap = allNames.groupingBy { it }.eachCount() + + // Sort by frequency (descending), then alphabetically as tiebreaker + return frequencyMap.keys + .sortedWith(compareByDescending { frequencyMap[it] ?: 0 }.thenBy { it.lowercase() }) + } + + /** + * Collect entity names from leaf elements that match the range predicate. + * This captures identifiers, variable usages, and other references using element text directly. + * Avoids calling element.references which can trigger expensive resolution in some languages. + */ + private fun collectReferenceNames( + psiFile: PsiFile, + rangeFilter: (TextRange) -> Boolean, + ): List { + val names = mutableListOf() + PsiTreeUtil.processElements(psiFile) { element -> + val elementRange = element.textRange + // Only process leaf elements (no children) to avoid getting large text blocks + if (elementRange != null && rangeFilter(elementRange) && element.firstChild == null) { + element.text.takeIf { isValidEntityName(it) }?.let { names.add(it) } + } + true // continue processing + } + return names + } + + /** + * Validate entity name for ghost text suggestions. + * Filters out: + * - Very short names (< 2 chars) - too generic + * - Names starting with underscore (private/internal by convention) + * - Names that don't match typical identifier patterns + */ + private fun isValidEntityName(name: String): Boolean = + name.length >= MIN_ENTITY_NAME_LENGTH && + !name.startsWith("_") && + name.matches(Regex("[a-zA-Z][a-zA-Z0-9_]*")) + + /** + * Get the cached current file path, or fetch from editor if not cached. + */ + fun getCurrentEditorFilePath(): String? { + entityLock.read { + if (currentFilePath != null) return currentFilePath + } + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return null + return editor.virtualFile?.path + } + + /** + * Check if the current file has changed from the cached path. + */ + fun hasFileChanged(): Boolean { + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return false + val editorPath = editor.virtualFile?.path + return entityLock.read { editorPath != currentFilePath } + } + + override fun dispose() { + // Remove scroll listener + currentScrollListener?.let { listener -> + currentEditor?.scrollingModel?.removeVisibleAreaListener(listener) + } + currentScrollListener = null + currentEditor = null + + // Cancel pending scroll updates + scrollDebounceAlarm.cancelAllRequests() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/FileSearcher.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/FileSearcher.kt new file mode 100644 index 0000000..fa1b03e --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/FileSearcher.kt @@ -0,0 +1,385 @@ +package com.oxidecode.services + +import com.intellij.ide.actions.GotoFileItemProvider +import com.intellij.ide.util.gotoByName.ChooseByNameViewModel +import com.intellij.ide.util.gotoByName.GotoFileModel +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.util.ProgressIndicatorBase +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.psi.PsiFileSystemItem +import com.intellij.util.Processor +import com.oxidecode.utils.relativePath +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean + +/** + * FileSearcherOptimized uses IntelliJ's built-in Search Everywhere infrastructure directly. + * + * This implementation leverages the same APIs that power: + * - Shift+Shift (Search Everywhere) + * - Cmd/Ctrl+Shift+N (Go to File) + * + * Benefits over custom implementation: + * - Uses IntelliJ's optimized indexing and caching + * - Same fuzzy matching algorithm as Search Everywhere + * - Consistent ranking and scoring with IDE behavior + * - Automatic support for all file types and custom providers + * - Respects project scope and filtering settings + * - Thread-safe and cancellable search operations + * + * The search uses GotoFileModel which provides: + * - CamelCase matching (e.g., "FS" matches "FileSearcher") + * - Fuzzy matching with intelligent ranking + * - Path matching (e.g., "src/main/File" matches files in that path) + * - Wildcard support (e.g., "*Controller.java") + * - Same performance optimizations as the IDE + */ +@Service(Service.Level.PROJECT) +class FileSearcher( + private val project: Project, +) : Disposable { + companion object { + private val logger = Logger.getInstance(FileSearcher::class.java) + + fun getInstance(project: Project): FileSearcher = project.getService(FileSearcher::class.java) + } + + fun contains(file: String): Boolean { + if (file.isBlank()) return false + if (project.isDisposed) return false + + return try { + ReadAction + .nonBlocking { + if (project.isDisposed) return@nonBlocking false + + try { + // Extract just the filename from the path + val fileName = file.substringAfterLast('/').substringAfterLast('\\') + + // Use FilenameIndex for ultra-fast lookup (already indexed by IntelliJ) + val files = + com.intellij.psi.search.FilenameIndex.getFilesByName( + project, + fileName, + com.intellij.psi.search.GlobalSearchScope + .projectScope(project), + ) + + // If no files with that name, quick return + if (files.isEmpty()) return@nonBlocking false + + // For exact path matching, check if any of the found files match the full path + files.any { psiFile -> + val virtualFile = psiFile.virtualFile + if (virtualFile != null) { + val relativePath = relativePath(project, virtualFile) + relativePath == file || relativePath?.endsWith(file) == true + } else { + false + } + } + } catch (e: ProcessCanceledException) { + throw e // Re-throw to allow proper cancellation + } catch (e: Exception) { + logger.warn("Error checking if file exists in project: $file", e) + false + } + }.expireWith(OxideCodeProjectService.getInstance(project)) + .executeSynchronously() + } catch (e: ProcessCanceledException) { + false // Search was cancelled + } + } + + /** + * Search for files using IntelliJ's Search Everywhere infrastructure. + * + * This method directly uses the same backend as Shift+Shift (Search Everywhere) + * and Cmd/Ctrl+Shift+N (Go to File), providing identical search behavior to what + * users experience in the IDE. + * + * Features: + * - Fuzzy matching: "fisr" matches "FileSearcher.kt" + * - CamelCase: "FS" matches "FileSearcher" + * - Path matching: "src/main/FS" for files in specific paths + * - Wildcards: "*Test.kt" for all test files + * - Smart ranking based on: + * - Match quality (exact > prefix > substring > fuzzy) + * - Recent usage and frequency + * - File location (project files ranked higher) + * + * @param pattern The search pattern (supports fuzzy, CamelCase, wildcards) + * @param maxResults Maximum number of results to return + * @return List of relative file paths, ranked by relevance + */ + fun searchFiles( + pattern: String, + maxResults: Int = 20, + ): List { + if (pattern.isBlank()) return emptyList() + if (project.isDisposed) return emptyList() + + return try { + ReadAction + .nonBlocking> { + if (project.isDisposed) return@nonBlocking emptyList() + + try { + val startTime = System.nanoTime() + + // Create the model and provider - same as IDE's Go to File + val modelStartTime = System.nanoTime() + val model = GotoFileModel(project) + val modelTime = (System.nanoTime() - modelStartTime) / 1_000_000.0 + logger.info("FileSearcher GotoFileModel creation for pattern '$pattern' took ${modelTime}ms") + + val providerStartTime = System.nanoTime() + val provider = GotoFileItemProvider(project, null, model) + val providerTime = (System.nanoTime() - providerStartTime) / 1_000_000.0 + logger.info("FileSearcher GotoFileItemProvider creation for pattern '$pattern' took ${providerTime}ms") + + // Collect results using a progress indicator + val resultsStartTime = System.nanoTime() + val results = ConcurrentLinkedQueue() + val cancelled = AtomicBoolean(false) + + val indicator = + object : ProgressIndicatorBase() { + init { + start() + } + } + val resultsTime = (System.nanoTime() - resultsStartTime) / 1_000_000.0 + logger.info("FileSearcher results queue and indicator creation for pattern '$pattern' took ${resultsTime}ms") + + // Create a simple view model for the search + val viewModelStartTime = System.nanoTime() + val viewModel = + object : ChooseByNameViewModel { + override fun getProject(): Project = this@FileSearcher.project + + override fun getModel() = model + + override fun isSearchInAnyPlace() = true // Search everywhere in the name + + override fun transformPattern(pattern: String) = pattern + + override fun canShowListForEmptyPattern() = false + + override fun getMaximumListSizeLimit() = maxResults + } + val viewModelTime = (System.nanoTime() - viewModelStartTime) / 1_000_000.0 + logger.info("FileSearcher ChooseByNameViewModel creation for pattern '$pattern' took ${viewModelTime}ms") + + val setupTime = modelTime + providerTime + resultsTime + viewModelTime + logger.info("FileSearcher total setup for pattern '$pattern' took ${setupTime}ms") + + // Use the provider to get filtered elements with proper ranking + val projectFileIndex = ProjectFileIndex.getInstance(project) + val processor = + Processor { element -> + if (results.size >= maxResults) { + indicator.cancel() + return@Processor false + } + + if (element is PsiFileSystemItem) { + val virtualFile = element.virtualFile + // Only include files that are in project content (exclude libraries, build outputs, etc.) + if (virtualFile != null && projectFileIndex.isInContent(virtualFile)) { + relativePath(project, virtualFile)?.let { path -> + results.add(path) + } + } + } + !indicator.isCanceled + } + + // Perform the search using the provider's filtering + val filterStartTime = System.nanoTime() + provider.filterElements(viewModel, pattern, false, indicator, processor) + val filterTime = (System.nanoTime() - filterStartTime) / 1_000_000.0 + logger.info( + "FileSearcher filterElements for pattern '$pattern' took ${filterTime}ms, found ${results.size} results", + ) + + // Return results (already in ranked order from the provider) + val collectStartTime = System.nanoTime() + val finalResults = results.take(maxResults).toList() + val collectTime = (System.nanoTime() - collectStartTime) / 1_000_000.0 + + val totalTime = (System.nanoTime() - startTime) / 1_000_000.0 + logger.info("FileSearcher collect results for pattern '$pattern' took ${collectTime}ms") + logger.info( + "FileSearcher total search for pattern '$pattern' took ${totalTime}ms, returned ${finalResults.size} results", + ) + + finalResults + } catch (e: ProcessCanceledException) { + throw e // Re-throw to allow proper cancellation + } catch (e: Exception) { + logger.warn("Error searching files with pattern: $pattern", e) + emptyList() + } + }.expireWith(OxideCodeProjectService.getInstance(project)) + .executeSynchronously() + } catch (e: ProcessCanceledException) { + emptyList() // Search was cancelled + } + } + + /** + * Alternative search method that processes results directly without collecting them first. + * More efficient for large result sets where you want to process items as they're found. + * + * @param pattern The search pattern + * @param processor Function to process each found file path. Return false to stop searching. + * @param maxResults Maximum number of results to process + * @return Number of items processed + */ + fun searchFilesWithProcessor( + pattern: String, + processor: (String) -> Boolean, + maxResults: Int = 100, + ): Int { + if (pattern.isBlank()) return 0 + if (project.isDisposed) return 0 + + return try { + ReadAction + .nonBlocking { + if (project.isDisposed) return@nonBlocking 0 + + try { + val model = GotoFileModel(project) + + val provider = GotoFileItemProvider(project, null, model) + var processedCount = 0 + + val indicator = + object : ProgressIndicatorBase() { + init { + start() + } + } + + val viewModel = + object : ChooseByNameViewModel { + override fun getProject(): Project = project + + override fun getModel() = model + + override fun isSearchInAnyPlace() = true + + override fun transformPattern(pattern: String) = pattern + + override fun canShowListForEmptyPattern() = false + + override fun getMaximumListSizeLimit() = maxResults + } + + val projectFileIndex = ProjectFileIndex.getInstance(project) + val itemProcessor = + Processor { element -> + if (processedCount >= maxResults) { + indicator.cancel() + return@Processor false + } + + if (element is PsiFileSystemItem) { + val virtualFile = element.virtualFile + // Only include files that are in project content (exclude libraries, build outputs, etc.) + if (virtualFile != null && projectFileIndex.isInContent(virtualFile)) { + relativePath(project, virtualFile)?.let { path -> + processedCount++ + if (!processor(path)) { + indicator.cancel() + return@Processor false + } + } + } + } + !indicator.isCanceled + } + + provider.filterElements(viewModel, pattern, false, indicator, itemProcessor) + processedCount + } catch (e: ProcessCanceledException) { + throw e // Re-throw to allow proper cancellation + } catch (e: Exception) { + logger.warn("Error processing files with pattern: $pattern", e) + 0 + } + }.expireWith(OxideCodeProjectService.getInstance(project)) + .executeSynchronously() + } catch (e: ProcessCanceledException) { + 0 // Search was cancelled + } + } + + /** + * Quick check if a file exists using the search infrastructure. + * This is more efficient than doing a full search when you just need to verify existence. + * + * @param filename The filename to check + * @return true if the file exists in the project + */ + fun fileExists(filename: String): Boolean { + var found = false + searchFilesWithProcessor(filename, { path -> + if (path.endsWith(filename) || path.endsWith("/$filename")) { + found = true + false // Stop searching + } else { + true // Continue + } + }, maxResults = Int.MAX_VALUE) + return found + } + + /** + * Get all files matching a suffix pattern using Search Everywhere. + * Uses wildcard pattern for optimal performance. + * + * @param suffix The suffix to match (e.g., "Controller.java" or ".kt") + * @param limit Maximum number of results + * @return List of file paths matching the suffix + */ + fun getFilesWithSuffix( + suffix: String, + limit: Int = 100, + ): List { + // Use wildcard pattern - Search Everywhere handles this efficiently + val pattern = if (suffix.startsWith("*")) suffix else "*$suffix" + return searchFiles(pattern, limit) + } + + /** + * Search for files using multiple patterns and combine results. + * Useful for complex searches where you want results from different patterns. + * + * @param patterns List of search patterns + * @param maxResultsPerPattern Maximum results per pattern + * @return Combined list of unique file paths + */ + fun searchFilesMultiPattern( + patterns: List, + maxResultsPerPattern: Int = 50, + ): List { + val allResults = mutableSetOf() + patterns.forEach { pattern -> + allResults.addAll(searchFiles(pattern, maxResultsPerPattern)) + } + return allResults.toList() + } + + override fun dispose() { + // Cleanup if needed + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/FileUsageManager.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/FileUsageManager.kt new file mode 100644 index 0000000..7a14281 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/FileUsageManager.kt @@ -0,0 +1,43 @@ +package com.oxidecode.services + +import com.intellij.openapi.components.* +import com.intellij.openapi.project.Project +import com.intellij.util.xmlb.XmlSerializerUtil +import java.time.Instant + +@State( + name = "FileUsageManager", + storages = [Storage("SweepFileUsage.xml")], +) +@Service(Service.Level.PROJECT) +class FileUsageManager : PersistentStateComponent { + private var fileUsages: MutableMap = mutableMapOf() + + data class FileUsageMetaData( + var count: Int = 0, + var timestamps: MutableList = mutableListOf(), + ) + + fun addOrRefreshUsage(fileName: String) { + val metadata = fileUsages.getOrPut(fileName) { FileUsageMetaData() } + metadata.count++ + metadata.timestamps.add(Instant.now().toEpochMilli()) + + // Keep only last 10 timestamps + if (metadata.timestamps.size > 10) { + metadata.timestamps = metadata.timestamps.takeLast(10).toMutableList() + } + } + + fun getUsages(): Map = fileUsages + + override fun getState(): FileUsageManager = this + + override fun loadState(state: FileUsageManager) { + XmlSerializerUtil.copyBean(state, this) + } + + companion object { + fun getInstance(project: Project): FileUsageManager = project.getService(FileUsageManager::class.java) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/GitIndexCleanupService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/GitIndexCleanupService.kt new file mode 100644 index 0000000..ed89fa8 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/GitIndexCleanupService.kt @@ -0,0 +1,134 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VfsUtil +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.VFileDeleteEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import java.io.File +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Service that manages cleanup of files added to git with --intent-to-add. + * + * When files are added with `git add --intent-to-add`, they become tracked by git + * but not staged. If these files are later deleted without being committed, they + * can persist in IntelliJ's VFS. This service tracks such files and automatically + * removes them from git's index when deleted. + */ +@Service(Service.Level.PROJECT) +class GitIndexCleanupService( + private val project: Project, +) : Disposable { + companion object { + fun getInstance(project: Project): GitIndexCleanupService = project.getService(GitIndexCleanupService::class.java) + } + + // Track files added with git --intent-to-add so we can clean them up if deleted + private val intentToAddFiles = CopyOnWriteArrayList() + + init { + // Set up VFS listener to clean up git index when intent-to-add files are deleted + val connection = project.messageBus.connect(this@GitIndexCleanupService) + connection.subscribe( + VirtualFileManager.VFS_CHANGES, + object : BulkFileListener { + override fun after(events: List) { + for (event in events) { + if (event is VFileDeleteEvent) { + val file = event.file + if (isIntentToAddFile(file.path)) { + // File was deleted and it was tracked with --intent-to-add + // Remove it from git index to prevent VFS persistence issues + ApplicationManager.getApplication().executeOnPooledThread { + unstageFileFromGit(file) + removeIntentToAddFile(file.path) + } + } + } + } + } + }, + ) + } + + /** + * Records a file path that was added to git with --intent-to-add + */ + fun recordIntentToAddFile(filePath: String) { + if (!intentToAddFiles.contains(filePath)) { + intentToAddFiles.add(filePath) + } + } + + /** + * Checks if a file path was added with --intent-to-add + */ + fun isIntentToAddFile(filePath: String): Boolean = intentToAddFiles.contains(filePath) + + /** + * Removes a file path from the intent-to-add tracking + */ + fun removeIntentToAddFile(filePath: String) { + intentToAddFiles.remove(filePath) + } + + /** + * Unstages a file from git index using git rm --cached. + * This removes the file from git's index without deleting it from the working directory. + */ + private fun unstageFileFromGit(virtualFile: VirtualFile) { + if (project.isDisposed) return + + try { + // Find the git root directory + val gitRoot = findGitRoot(virtualFile) ?: return + + // Get relative path from git root + val relativePath = VfsUtil.getRelativePath(virtualFile, gitRoot) ?: return + + // Execute git rm --cached to remove from index + val processBuilder = ProcessBuilder("git", "rm", "--cached", "--quiet", relativePath) + processBuilder.directory(File(gitRoot.path)) + + var process: Process? = null + try { + process = processBuilder.start() + val exitCode = process.waitFor() + + if (exitCode != 0) { + val errorOutput = process.errorStream.bufferedReader().readText() + thisLogger().debug("Git rm --cached failed with exit code $exitCode: $errorOutput") + } + } finally { + process?.destroy() + } + } catch (e: Exception) { + thisLogger().debug("Failed to unstage file from git: ${e.message}") + } + } + + /** + * Finds the git root directory by walking up the directory tree. + */ + private fun findGitRoot(file: VirtualFile): VirtualFile? { + var current = if (file.isDirectory) file else file.parent + while (current != null) { + if (current.findChild(".git") != null) { + return current + } + current = current.parent + } + return null + } + + override fun dispose() { + // just need to trigger the disposal event, no resources to clean up + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/IdeaVimIntegrationService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/IdeaVimIntegrationService.kt new file mode 100644 index 0000000..6826325 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/IdeaVimIntegrationService.kt @@ -0,0 +1,178 @@ +package com.oxidecode.services + +import com.intellij.ide.DataManager +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.IdeActions.ACTION_EDITOR_ESCAPE +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ex.ApplicationManagerEx +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.EditorActionManager +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.project.Project +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardOpenOption + +@Service(Service.Level.PROJECT) +class IdeaVimIntegrationService( + private val project: Project, +) { + companion object { + private const val IDEAVIM_PLUGIN_ID = "IdeaVIM" + private const val SWEEP_TAB_MAPPING = "map :action com.oxidecode.autocomplete.edit.AcceptEditCompletionAction" + private const val SWEEP_MAPPING_COMMENT = "\" Sweep AI Tab completion mapping" + + fun getInstance(project: Project): IdeaVimIntegrationService = project.getService(IdeaVimIntegrationService::class.java) + } + + private val logger = Logger.getInstance(IdeaVimIntegrationService::class.java) + + /** + * Checks if IdeaVim plugin is installed and enabled + */ + fun isIdeaVimActive(): Boolean { + val pluginId = PluginId.getId(IDEAVIM_PLUGIN_ID) + return PluginManagerCore.isPluginInstalled(pluginId) && + PluginManagerCore.getPlugin(pluginId)?.isEnabled == true + } + + /** + * Calls vim escape to ensure user enters normal mode when popup is explicitly closed. + * This method centralizes the vim escape logic used by various popup components. + */ + fun callVimEscape(editor: Editor) { + // Presses ESC to exit insert mode in vim + if (isIdeaVimActive()) { + val dataContext = DataManager.getInstance().getDataContext(editor.component) + val escHandler = EditorActionManager.getInstance().getActionHandler(ACTION_EDITOR_ESCAPE) + escHandler.execute(editor, editor.caretModel.currentCaret, dataContext) + } + } + + /** + * Checks if showing ghost text at the given position would conflict with VIM plugin + */ + fun wouldConflictWithVim( + editor: Editor, + offset: Int, + ): Boolean { + if (!isIdeaVimActive()) return false + + // VIM plugin has issues with inlays at column 0 + val document = editor.document + val line = document.getLineNumber(offset) + val lineStartOffset = document.getLineStartOffset(line) + return offset == lineStartOffset && editor.caretModel.offset == lineStartOffset + } + + /** + * Checks if IdeaVim plugin is installed and enabled + * @deprecated Use isIdeaVimActive() instead + */ + private fun isIdeaVimInstalled(): Boolean = isIdeaVimActive() + + /** + * Gets the path to the user's .ideavimrc file + */ + private fun getIdeavimrcPath(): File { + val userHome = System.getProperty("user.home") + return File(userHome, ".ideavimrc") + } + + /** + * Checks if the Sweep Tab mapping already exists in .ideavimrc + */ + private fun hasSweepTabMapping(ideavimrcFile: File): Boolean { + if (!ideavimrcFile.exists()) { + return false + } + + return try { + val content = ideavimrcFile.readText() + content.contains(SWEEP_TAB_MAPPING) + } catch (e: Exception) { + logger.warn("Error reading .ideavimrc file", e) + false + } + } + + /** + * Adds the Sweep Tab mapping to the .ideavimrc file + */ + private fun addSweepTabMapping(ideavimrcFile: File) { + try { + val mappingWithComment = "\n$SWEEP_MAPPING_COMMENT\n$SWEEP_TAB_MAPPING\n" + + if (ideavimrcFile.exists()) { + // Append to existing file + Files.write( + ideavimrcFile.toPath(), + mappingWithComment.toByteArray(), + StandardOpenOption.APPEND, + ) + } else { + // Create new file + Files.write( + ideavimrcFile.toPath(), + mappingWithComment.toByteArray(), + StandardOpenOption.CREATE, + ) + } + + logger.info("Successfully added Sweep Tab mapping to .ideavimrc") + } catch (e: Exception) { + logger.warn("Error adding Sweep Tab mapping to .ideavimrc", e) + } + } + + /** + * Shows a notification asking the user to restart their IDE with a restart button + */ + private fun showRestartNotification() { + ApplicationManager.getApplication().invokeLater { + val notification = + NotificationGroupManager + .getInstance() + .getNotificationGroup("Sweep AI Notifications") + .createNotification( + "IdeaVim Integration Complete", + "Sweep has configured your Vim settings for Tab completion. Please restart your IDE to activate the changes.", + NotificationType.INFORMATION, + ).addAction( + NotificationAction.createSimpleExpiring("Restart now") { + ApplicationManagerEx.getApplicationEx().restart(true) + }, + ) + + notification.notify(project) + } + } + + /** + * Configures IdeaVim integration if the plugin is installed + */ + fun configureIdeaVimIntegration() { + ApplicationManager.getApplication().executeOnPooledThread { + if (!isIdeaVimActive()) { + logger.info("IdeaVim plugin not installed or not enabled, skipping configuration") + return@executeOnPooledThread + } + + val ideavimrcFile = getIdeavimrcPath() + + if (hasSweepTabMapping(ideavimrcFile)) { + logger.info("Sweep Tab mapping already exists in .ideavimrc") + return@executeOnPooledThread + } + + logger.info("IdeaVim detected, adding Sweep Tab mapping to .ideavimrc") + addSweepTabMapping(ideavimrcFile) + showRestartNotification() + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/NotificationDeduplicationService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/NotificationDeduplicationService.kt new file mode 100644 index 0000000..0fd6830 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/NotificationDeduplicationService.kt @@ -0,0 +1,299 @@ +package com.oxidecode.services + +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.IdeaLoggingEvent +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.oxidecode.settings.OxideCodeSettings +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.concurrent.ConcurrentHashMap +import javax.swing.JPanel + +/** + * Service that handles deduplication of notifications to prevent spam. + * Uses token overlap detection to determine if notifications are similar enough to be deduplicated. + */ +@Service(Service.Level.PROJECT) +class NotificationDeduplicationService( + private val project: Project, +) : Disposable { + companion object { + fun getInstance(project: Project): NotificationDeduplicationService = + project.getService(NotificationDeduplicationService::class.java) + + private const val TOKEN_OVERLAP_THRESHOLD = 0.8 // 80% token overlap threshold + + /** + * Checks if the backend health endpoint is reachable. + * Returns true if backend is reachable, false if there are network/connectivity issues. + */ + @RequiresBackgroundThread + private fun isBackendHealthy(): Boolean = + try { + runBlocking { + withTimeoutOrNull(2000) { + val baseUrl = OxideCodeSettings.getInstance().baseUrl + val httpClient = + HttpClient + .newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build() + val request = + HttpRequest + .newBuilder() + .uri(URI.create(baseUrl)) + .timeout(Duration.ofSeconds(3)) + .GET() + .build() + val response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()) + response.statusCode() in 200..299 + } ?: false + } + } catch (e: Exception) { + false + } + + /** + * Creates user-friendly error messages for common HTTP and other errors. + * Returns a pair of (userFriendlyTitle, userFriendlyContent) for display, + * while preserving the original exception for error reporting. + * Returns null if the error should fail silently (no user notification). + */ + private fun createUserFriendlyErrorMessage( + exception: Exception, + originalTitle: String, + project: Project, + ): Pair? { + val message = exception.toString() + val exceptionType = exception::class.java.simpleName + + return when { + // Timeout errors - fail silently + exceptionType == "HttpTimeoutException" || message.contains("request timed out") -> { + null + } + // HTTP 401 - Unauthorized + message.contains("HTTP 401") -> { + "Authentication Error" to + "Your token appears to be invalid or expired. Please check your Sweep settings and update your token." + } + + // HTTP 403 - Forbidden + message.contains("HTTP 403") -> { + "Authentication Error" to + "Your credentials for Sweep are misconfigured. Please check your Sweep settings and try again." + } + + // HTTP 404 - Not Found + message.contains("HTTP 404") -> { + "Service Unavailable" to "Sweep service endpoint not found. This may be a temporary issue. Please try again later." + } + + // HTTP 407 - Proxy Authentication Required + message.contains("HTTP 407") -> { + "Proxy Authentication Required" to + "Sweep's autocomplete cannot connect because your network proxy requires authentication. " + + "Please configure your proxy credentials in Settings > Appearance & Behavior > System Settings > HTTP Proxy or email support@sweep.dev." + } + + // HTTP 429 - Too Many Requests + message.contains("HTTP 429") -> { + "Rate Limited" to "Too many requests sent to Sweep's autocomplete. Please wait a moment before trying again." + } + + // HTTP 500, 502, 503, 504 - Server Errors + message.contains(Regex("HTTP (500|502|503|504)")) -> { + "Service Error" to "Sweep's autocomplete are temporarily unavailable. Please try again in a few minutes." + } + + // Network/Connection errors + message.contains("Connection", ignoreCase = true) || + message.contains("timeout", ignoreCase = true) || + message.contains("ConnectException", ignoreCase = true) || + message.contains("Connection reset") || + message.contains("header parser received no bytes") || + message.contains("closed") || + exceptionType == "SocketException" -> { + null // Fail silently + } + + // SSL/Certificate errors + message.contains("SSL", ignoreCase = true) || + message.contains("certificate", ignoreCase = true) -> { + "Security Error" to + "SSL certificate verification failed. Please check your network security settings or try again later." + } + + // Access Control errors (Java Security Manager blocking network access) + exceptionType == "AccessControlException" || message.contains("access denied") -> { + "Permission Error" to + "Access to Sweep's autocomplete services was blocked by Java Security Manager. " + + "Please check: " + + "(1) Help > Edit Custom VM Options for any '-Djava.security.manager' flags, " + + "(2) Corporate security or antivirus software that may be restricting Java network access, " + + "(3) Custom java.policy files in your JDK installation. " + + "For more help, contact support@sweep.dev" + } + + // Default case - use original message but make it more user-friendly + else -> { + originalTitle to + "An error occurred while using Sweep's autocomplete. Please try again or check your settings if the problem persists." + } + } + } + } + + private data class NotificationRecord( + val originalTitle: String, + val originalContent: String, + val userFriendlyTitle: String, + val userFriendlyContent: String, + val notificationGroup: String, + val type: NotificationType, + ) { + val tokens: Set by lazy { tokenize(originalContent) } + + companion object { + /** + * Tokenizes a string into a set of normalized tokens for comparison. + */ + private fun tokenize(text: String): Set = + text + .lowercase() + .replace(Regex("[^a-zA-Z0-9\\s]"), " ") // Replace non-alphanumeric with spaces + .split(Regex("\\s+")) // Split on whitespace + .filter { it.length > 2 } // Filter out very short tokens + .toSet() + } + } + + // Cache of shown notifications for deduplication (never expires) + private val shownNotifications = ConcurrentHashMap.newKeySet() + private var isDisposed = false + + /** + * Shows a notification with deduplication based on token overlap. + * If a similar notification was ever shown, this call will be ignored. + */ + fun showNotificationWithDeduplication( + title: String, + content: String, + notificationGroup: String, + type: NotificationType = NotificationType.INFORMATION, + ) { + if (isDisposed) return + + val newRecord = NotificationRecord(title, content, title, content, notificationGroup, type) + + // Check for similar notifications that were ever shown + if (shouldDeduplicate(newRecord)) { + println("Deduplicated notification: $title") + return + } + + // Store this notification for future deduplication + shownNotifications.add(newRecord) + + // Show the notification + ApplicationManager.getApplication().invokeLater { + if (!isDisposed && !project.isDisposed) { + NotificationGroupManager + .getInstance() + .getNotificationGroup(notificationGroup) + .createNotification(title, content, type) + .notify(project) + } + } + } + + /** + * Shows a notification with deduplication and also sends an error report if the notification passes deduplication. + * If a similar notification was ever shown, both the notification and error report will be ignored. + */ + fun showNotificationWithDeduplicationAndErrorReporting( + title: String, + content: String, + notificationGroup: String, + type: NotificationType = NotificationType.INFORMATION, + exception: Exception, + errorContext: String, + ) { + if (isDisposed) return + + // Create user-friendly error message for display (null means fail silently) + val userFriendlyMessage = createUserFriendlyErrorMessage(exception, title, project) + val (userFriendlyTitle, userFriendlyContent) = userFriendlyMessage ?: (title to content) + val newRecord = NotificationRecord(title, content, userFriendlyTitle, userFriendlyContent, notificationGroup, type) + + // Early return if this is a duplicate + if (shouldDeduplicate(newRecord)) { + println("Deduplicated notification: $title") + return + } + + // Store this notification for future deduplication + shownNotifications.add(newRecord) + + // If we have a user-friendly message, show it to the user + if (userFriendlyMessage != null) { + // Show the user-friendly notification + ApplicationManager.getApplication().invokeLater { + if (!isDisposed && !project.isDisposed) { + NotificationGroupManager + .getInstance() + .getNotificationGroup(notificationGroup) + .createNotification(userFriendlyTitle, userFriendlyContent, type) + .notify(project) + } + } + } else { + println("Failing silently for error: ${exception.message}") + } + } + + /** + * Determines if a notification should be deduplicated based on token overlap with previously shown notifications. + */ + private fun shouldDeduplicate(newRecord: NotificationRecord): Boolean = + shownNotifications.any { existingRecord -> + // Only compare notifications from the same group and type + existingRecord.notificationGroup == newRecord.notificationGroup && + existingRecord.type == newRecord.type && + existingRecord.originalTitle == newRecord.originalTitle && + calculateTokenOverlap(existingRecord.tokens, newRecord.tokens) >= TOKEN_OVERLAP_THRESHOLD + } + + /** + * Calculates the token overlap between two sets of tokens. + * Returns a value between 0.0 and 1.0, where 1.0 means identical token sets. + */ + private fun calculateTokenOverlap( + tokens1: Set, + tokens2: Set, + ): Double { + if (tokens1.isEmpty() && tokens2.isEmpty()) return 1.0 + if (tokens1.isEmpty() || tokens2.isEmpty()) return 0.0 + + val intersection = tokens1.intersect(tokens2) + val union = tokens1.union(tokens2) + + return intersection.size.toDouble() / union.size.toDouble() + } + + override fun dispose() { + isDisposed = true + shownNotifications.clear() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeColorChangeService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeColorChangeService.kt new file mode 100644 index 0000000..ad23a41 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeColorChangeService.kt @@ -0,0 +1,50 @@ +package com.oxidecode.services + +import com.intellij.ide.ui.LafManagerListener +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.oxidecode.theme.OxideCodeColors + +@Service(Service.Level.PROJECT) +class OxideCodeColorChangeService( + private val project: Project, +) : Disposable { + companion object { + fun getInstance(project: Project): OxideCodeColorChangeService = project.getService(OxideCodeColorChangeService::class.java) + } + + init { + // Create a message bus connection that is automatically disposed with this object + val messageBusConnection = ApplicationManager.getApplication().messageBus.connect(this) + messageBusConnection.subscribe( + LafManagerListener.TOPIC, + LafManagerListener { + ApplicationManager.getApplication().invokeLater { + // Refresh colors in OxideCodeColors + OxideCodeColors.refreshColors() + } + }, + ) + } + + fun addThemeChangeListener( + disposable: Disposable, + handler: () -> Unit, + ) { + val messageBusConnection = ApplicationManager.getApplication().messageBus.connect(disposable) + messageBusConnection.subscribe( + LafManagerListener.TOPIC, + LafManagerListener { + ApplicationManager.getApplication().invokeLater { + handler() + } + }, + ) + } + + override fun dispose() { + // No manual cleanup needed - message bus connections are automatically disposed + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeConstantsService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeConstantsService.kt new file mode 100644 index 0000000..565d9e5 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeConstantsService.kt @@ -0,0 +1,31 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.PROJECT) +class OxideCodeConstantsService( + private val project: Project, +) : Disposable { + private val _repoName = ConcurrentHashMap() + + var repoName: String? + get() = _repoName[project.locationHash] + set(value) { + if (value != null) { + _repoName[project.locationHash] = value + } else { + _repoName.remove(project.locationHash) + } + } + + override fun dispose() { + _repoName.clear() + } + + companion object { + fun getInstance(project: Project): OxideCodeConstantsService = project.getService(OxideCodeConstantsService::class.java) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeNonProjectFilesService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeNonProjectFilesService.kt new file mode 100644 index 0000000..c9de26c --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeNonProjectFilesService.kt @@ -0,0 +1,86 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.oxidecode.utils.BLOCKED_URL_PREFIXES +import com.oxidecode.utils.getVirtualFile +import com.oxidecode.utils.toAbsolutePath + +// allows non project module files to interact with oxide code +@Service(Service.Level.PROJECT) +class OxideCodeNonProjectFilesService( + private val project: Project, +) : Disposable { + private val allowedNonProjectFiles: MutableList = mutableListOf() + private val maxSize = 100 + + fun addAllowedFile(filePath: String): Boolean { + val absolutePath = toAbsolutePath(filePath, project) ?: return false + if (allowedNonProjectFiles.size >= maxSize) { + allowedNonProjectFiles.removeAt(0) + } + return allowedNonProjectFiles.add(absolutePath) + } + + fun getVirtualFileAssociatedWithAllowedFile( + project: Project, + url: String, + ): VirtualFile? { + // Block files with blocked URL prefixes + if (BLOCKED_URL_PREFIXES.any { url.startsWith(it) }) { + return null + } + if (!isAllowedFile(url)) { + return null + } + val virtualFile = + VirtualFileManager.getInstance().findFileByUrl(url) + ?: if (url.startsWith("mock://")) { + getVirtualFile(project, url.removePrefix("mock://")) + } else { + getVirtualFile(project, url) + } + return virtualFile + } + + fun removeAllowedFile(filePath: String): Boolean = allowedNonProjectFiles.remove(filePath) + + fun getAllowedFiles(): List = allowedNonProjectFiles.toList() + + fun isAllowedFile(url: String): Boolean { + // Block files with blocked URL prefixes + if (BLOCKED_URL_PREFIXES.any { url.startsWith(it) }) { + return false + } + val absolutePath = toAbsolutePath(url, project) ?: return false + return allowedNonProjectFiles.contains(absolutePath) || + (url.startsWith("mock://") && allowedNonProjectFiles.contains(url.replace("mock://", ""))) + } + + fun getContentsOfAllowedFile( + project: Project, + url: String, + ): String? { + val virtualFile = getVirtualFileAssociatedWithAllowedFile(project, url) ?: return null + return try { + ApplicationManager.getApplication().runReadAction { + val document = FileDocumentManager.getInstance().getDocument(virtualFile) ?: return@runReadAction null + document.text + } + } catch (e: Exception) { + null + } + } + + companion object { + fun getInstance(project: Project): OxideCodeNonProjectFilesService = project.getService(OxideCodeNonProjectFilesService::class.java) + } + + override fun dispose() { + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeProblemRetriever.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeProblemRetriever.kt new file mode 100644 index 0000000..ad3e39a --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeProblemRetriever.kt @@ -0,0 +1,52 @@ +package com.oxidecode.services + +import com.intellij.codeInsight.daemon.impl.DaemonCodeAnalyzerEx +import com.intellij.codeInsight.daemon.impl.HighlightInfo +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile + +object OxideCodeProblemRetriever { + /** + * Retrieves problems similar to those shown in the "Problems" tool window (File tab) + * by accessing the underlying analysis results via internal API. + * WARNING: Uses internal API, which might change between IDE versions. + * + * @param project The current project. + * @param psiFile The file to analyze. + * @return A list of HighlightInfo objects representing the problems. + */ + fun getProblemsDisplayedInProblemsView( + project: Project, + psiFile: PsiFile, + minSeverity: HighlightSeverity = HighlightSeverity.WEAK_WARNING, + ): List { + val problems: MutableList = ArrayList() + ReadAction.run { + val document = PsiDocumentManager.getInstance(project).getDocument(psiFile) + + if (document == null) { + System.err.println("Could not get document for file: " + psiFile.name) + return@run + } + // This fetches the highlights that feed the editor AND likely the Problems view + DaemonCodeAnalyzerEx.processHighlights( + document, + project, + HighlightSeverity.INFORMATION, // Collect everything from INFO level up + 0, + document.textLength, + ) { highlightInfo: HighlightInfo -> + // Filter for severities based on minSeverity parameter + if (highlightInfo.severity.myVal >= minSeverity.myVal) { + problems.add(highlightInfo) + } + true // Continue processing + } + } + + return problems + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeProjectService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeProjectService.kt new file mode 100644 index 0000000..fc41677 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/OxideCodeProjectService.kt @@ -0,0 +1,19 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project + +@Service(Service.Level.PROJECT) +class OxideCodeProjectService : Disposable { + // Session-level flag to show shortcut notification only once per project session + var hasShownShortcutNotificationThisSession = false + + override fun dispose() { + // Nothing to do - just exists for lifecycle management + } + + companion object { + fun getInstance(project: Project): OxideCodeProjectService = project.getService(OxideCodeProjectService::class.java) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/SessionMessageList.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/SessionMessageList.kt new file mode 100644 index 0000000..23dfa6b --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/SessionMessageList.kt @@ -0,0 +1,330 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.oxidecode.data.FileInfo +import com.oxidecode.data.Message +import com.oxidecode.data.MessageRole +import com.oxidecode.utils.OxideCodeConstants +import java.io.File +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +/** + * Per-session message store for Sweep chat. + * + * This is a non-service class that holds the messages, conversation state, and metadata + * for a single chat session. Each SweepSession owns one instance of this class. + * + * Thread-safety: All operations are guarded by a ReentrantReadWriteLock. + * See the MessageList documentation in sweep_md_rules for usage patterns. + * + * @param project The IntelliJ project (used for file operations, not for service lookup) + * @param initialConversationId Optional initial conversation ID (defaults to a new UUID) + */ +class SessionMessageList( + private val project: Project, + initialConversationId: String = UUID.randomUUID().toString(), +) : Disposable { + private val logger = Logger.getInstance(SessionMessageList::class.java) + private val lock = ReentrantReadWriteLock() + private val messages = ArrayList() + + private val _conversationId = AtomicReference(initialConversationId) + private val _selectedModel = AtomicReference(null) + private val _uniqueChatID = AtomicReference(UUID.randomUUID().toString()) + + // Running total cost for the *entire thread*, in cents. + // Stored as a long of milli-cents to avoid floating point drift. + // Example: 12.345 cents -> 12345 milli-cents + private val _threadCostMilliCents = AtomicLong(0L) + + // Optional callback when conversationId changes (used by SweepSession to sync state) + var onConversationIdChanged: ((String) -> Unit)? = null + + override fun dispose() { + lock.write { messages.clear() } + _threadCostMilliCents.set(0L) + onConversationIdChanged = null + } + + var conversationId: String + get() = _conversationId.get() + set(value) { + val oldValue = _conversationId.getAndSet(value) + if (oldValue != value) { + onConversationIdChanged?.invoke(value) + } + } + + var selectedModel: String? + get() = _selectedModel.get() + set(value) { + _selectedModel.set(value) + } + + var uniqueChatID: String + get() = _uniqueChatID.get() + set(value) { + _uniqueChatID.set(value) + } + + /** Total cost for this conversation thread, in cents. */ + val threadCostCents: Double + get() = _threadCostMilliCents.get().toDouble() / 1000.0 + + /** Naively increments the thread cost total (used for retry/edit flows too). */ + fun addThreadCostCents(costCents: Double) { + if (costCents <= 0.0) return + val deltaMilliCents = (costCents * 1000.0).toLong() + if (deltaMilliCents <= 0L) return + _threadCostMilliCents.addAndGet(deltaMilliCents) + } + + private fun resetThreadCostFromMessages(list: List) { + val totalCents = + list.sumOf { message -> + message.annotations?.tokenUsage?.costWithMarkupCents ?: 0.0 + } + _threadCostMilliCents.set((totalCents * 1000.0).toLong()) + } + + fun regenerateUniqueChatID() { + _uniqueChatID.set(UUID.randomUUID().toString()) + } + + // ===== Read Operations (thread-safe) ===== + + fun snapshot(): List = lock.read { messages.toList() } + + fun size(): Int = lock.read { messages.size } + + fun isEmpty(): Boolean = lock.read { messages.isEmpty() } + + fun isNotEmpty(): Boolean = lock.read { messages.isNotEmpty() } + + fun getOrNull(index: Int): Message? = lock.read { messages.getOrNull(index) } + + fun get(index: Int): Message = lock.read { messages[index] } + + fun first(): Message = lock.read { messages.first() } + + fun last(): Message = lock.read { messages.last() } + + fun firstOrNull(): Message? = lock.read { messages.firstOrNull() } + + fun lastOrNull(): Message? = lock.read { messages.lastOrNull() } + + fun lastOrNull(predicate: (Message) -> Boolean): Message? = + lock.read { + messages.lastOrNull(predicate) + } + + fun firstOrNull(predicate: (Message) -> Boolean): Message? = + lock.read { + messages.firstOrNull(predicate) + } + + fun indexOfFirst(predicate: (Message) -> Boolean): Int = + lock.read { + messages.indexOfFirst(predicate) + } + + fun indexOfLast(predicate: (Message) -> Boolean): Int = + lock.read { + messages.indexOfLast(predicate) + } + + fun indexOf(element: Message): Int = lock.read { messages.indexOf(element) } + + fun contains(element: Message): Boolean = lock.read { messages.contains(element) } + + fun filter(predicate: (Message) -> Boolean): List = + lock.read { + messages.filter(predicate) + } + + fun find(predicate: (Message) -> Boolean): Message? = + lock.read { + messages.find(predicate) + } + + fun map(transform: (Message) -> R): List = + lock.read { + messages.map(transform) + } + + fun toList(): List = snapshot() + + fun toMutableList(): MutableList = lock.read { messages.toMutableList() } + + // Role-specific helpers + private fun lastOrNullByRole(role: MessageRole): Message? = + lock.read { + messages.lastOrNull { it.role == role } + } + + fun indexOfFirstRole(role: MessageRole): Int = + lock.read { + messages.indexOfFirst { it.role == role } + } + + fun indexOfLastRole(role: MessageRole): Int = + lock.read { + messages.indexOfLast { it.role == role } + } + + // Legacy compatibility methods + fun getLastUserQuery(): String? = getLastUserMessage()?.content + + fun getLastUserMessage(): Message? = lastOrNullByRole(MessageRole.USER) + + fun getCurrentMentionedFilesForUserMessage(index: Int): List = + getOrNull(index) + ?.takeIf { it.role == MessageRole.USER } + ?.mentionedFiles ?: emptyList() + + // ===== Write Operations (thread-safe) ===== + + fun add(message: Message): Boolean = lock.write { messages.add(message) } + + fun addMessage(message: Message) = add(message) + + fun addAll(elements: Collection): Boolean = lock.write { messages.addAll(elements) } + + fun addAllMessages(elements: Collection) = addAll(elements) + + fun updateAt( + index: Int, + transform: (Message) -> Message, + ): Message? = + lock.write { + val current = messages.getOrNull(index) ?: return@write null + val updated = transform(current) + messages[index] = updated + updated + } + + // Operator overload for backwards compatibility (DEPRECATED) + operator fun set( + index: Int, + element: Message, + ): Message = + lock.write { + val old = messages[index] + messages[index] = element + old + } + + fun removeAt(index: Int): Message = lock.write { messages.removeAt(index) } + + fun remove(element: Message): Boolean = lock.write { messages.remove(element) } + + fun clear() = + lock.write { + messages.clear() + _threadCostMilliCents.set(0L) + } + + /** + * Clears all messages and adds new ones. + * @param list The new messages to add + * @param resetConversationId If true, generates a new conversation ID + */ + fun clearAndAddAll( + list: List, + resetConversationId: Boolean = true, + ) { + lock.write { + messages.clear() + messages.addAll(list) + } + if (resetConversationId) { + conversationId = UUID.randomUUID().toString() + } + } + + /** + * Resets the message list with new messages. + * @param newList The new messages (defaults to empty) + * @param resetConversationId If true, generates a new conversation ID + * @return This SessionMessageList for chaining + */ + fun resetMessages( + newList: List = emptyList(), + resetConversationId: Boolean = true, + ): SessionMessageList { + clearAndAddAll(newList, resetConversationId) + resetThreadCostFromMessages(newList) + return this + } + + /** + * Prepares the message list for sending to the API. + * Cleans up temporary file snippets. + */ + fun prepareMessageListForSending(selectedModel: String? = null) { + _selectedModel.set(selectedModel) + + // Compute deletion targets from a stable snapshot off-lock + val snapshot = snapshot() + val toDeleteMentionedFiles = mutableListOf() + + snapshot.filter { it.role == MessageRole.USER }.forEach { message -> + for (entry in message.mentionedFiles) { + if (entry.span == null && + entry.codeSnippet != null && + entry.name.startsWith(OxideCodeConstants.GENERAL_TEXT_SNIPPET_PREFIX) + ) { + toDeleteMentionedFiles.add(entry) + } + } + } + + // File IO off the EDT + ApplicationManager.getApplication().executeOnPooledThread { + toDeleteMentionedFiles.forEach { fileInfo -> + try { + val file = File(fileInfo.relativePath) + if (file.exists()) { + file.delete() + } + } catch (e: Exception) { + logger.warn("Failed to delete file: ${fileInfo.relativePath}", e) + } + } + } + + // Remove deleted files in a short write section + if (toDeleteMentionedFiles.isNotEmpty()) { + lock.write { + for (i in messages.indices) { + val message = messages[i] + if (message.role == MessageRole.USER) { + val filteredFiles = message.mentionedFiles.filterNot { it in toDeleteMentionedFiles } + if (filteredFiles.size != message.mentionedFiles.size) { + // Create a new message with updated mentionedFiles to maintain immutability + messages[i] = message.copy(mentionedFiles = filteredFiles) + } + } + } + } + } + } + + fun prepareForSending(selectedModel: String? = null) = prepareMessageListForSending(selectedModel) + + // ===== Iterator Support (creates snapshot) ===== + + operator fun iterator(): Iterator = snapshot().iterator() + + fun forEach(action: (Message) -> Unit) = snapshot().forEach(action) + + fun forEachIndexed(action: (index: Int, Message) -> Unit) = snapshot().forEachIndexed(action) +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/StreamStateService.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/StreamStateService.kt new file mode 100644 index 0000000..b2a2fe5 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/StreamStateService.kt @@ -0,0 +1,54 @@ +package com.oxidecode.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project + +/** + * Listener type for stream state changes. + * Parameters: isStreaming, isSearching, streamStarted, conversationId (nullable for legacy callers) + */ +typealias StreamStateListener = (Boolean, Boolean, Boolean, String?) -> Unit + +@Service(Service.Level.PROJECT) +class StreamStateService( + private val project: Project, +) : Disposable { + private val listeners = mutableListOf() + + fun addListener(listener: StreamStateListener) { + listeners.add(listener) + } + + fun removeListener(listener: StreamStateListener) { + listeners.remove(listener) + } + + /** + * Notifies listeners of a stream state change. + * @param isStreaming Whether a stream is currently active + * @param isSearching Whether a search is currently active + * @param streamStarted Whether a stream has just started + * @param conversationId The conversation ID this notification is for (null for legacy callers) + */ + fun notify( + isStreaming: Boolean, + isSearching: Boolean = false, + streamStarted: Boolean = false, + conversationId: String? = null, + ) { + // Make sure UI updates run on the EDT + ApplicationManager.getApplication().invokeLater { + listeners.forEach { it.invoke(isStreaming, isSearching, streamStarted, conversationId) } + } + } + + override fun dispose() { + listeners.clear() + } + + companion object { + fun getInstance(project: Project): StreamStateService = project.getService(StreamStateService::class.java) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeMetaData.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeMetaData.kt new file mode 100644 index 0000000..33b12ec --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeMetaData.kt @@ -0,0 +1,562 @@ +package com.oxidecode.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage + +@State(name = "OxideCodeMetaData", storages = [Storage("OxideCodeMetaData.xml")]) +class OxideCodeMetaData : PersistentStateComponent { + data class MetaData( + var lastNotifiedVersion: String? = null, + var historyButtonClicks: Int = 0, + var newButtonClicks: Int = 0, + var commitMessageButtonClicks: Int = 0, + var configButtonClicks: Int = 0, + var reportButtonClicks: Int = 0, + var applyButtonClicks: Int = 0, + var hasSeenTutorialV2: Boolean = false, + var hasSeenChatTutorial: Boolean = false, + var suggestedUserInputCount: Int = 0, + var acceptedSuggestedUserInputCount: Int = 0, + var rejectedSuggestedUserInputCount: Int = 0, + var chatWithSearch: Int = 0, + var chatWithoutSearch: Int = 0, + var fileContextUsageCount: Int = 0, + var chatsSent: Int = 0, + var projectFullSyncedList: List = emptyList(), + var hasUsedFileShortcut: Boolean = false, + var hasShownFileShortcutBalloon: Boolean = false, + var hasShownNewChatBalloon: Boolean = false, + var hasShownClickToAddFilesBalloon: Boolean = false, + var chatHistoryUsed: Int = 0, + var chatHistoryBalloonWasShown: Boolean = false, + var hasShownProblemsWindow: Boolean = false, + var hasShownSearchPopup: Boolean = false, + var hasShownAgentPopup: Boolean = false, + var ghostTextTabAcceptCount: Int = 0, + var modelToggleUsed: Boolean = false, + var chatModeToggleUsed: Boolean = false, + var hasHandledPluginConflictsOnFirstInstall: Boolean = false, + var hasSeenInstallationTelemetryEvent: Boolean = false, + var isToolWindowVisible: Boolean = true, + // Format: "_true" or "_false" + var finishedFilesCachePopulationList: MutableList = mutableListOf(), + // Format: "_" + var lastIndexedFileList: MutableList = mutableListOf(), + // Format: "_" + var lastIndexedEntityFileList: MutableList = mutableListOf(), + // Format: "_" + var lastKnownFileCountList: MutableList = mutableListOf(), + // Format: "_true" or "_false" + var finishedEntitiesCachePopulationList: MutableList = mutableListOf(), + // List of version numbers for which update notifications have been shown + var shownUpdateVersions: MutableList = mutableListOf(), + // Format: "_" + var defaultBranchListForFileAutocomplete: MutableList = mutableListOf(), + var privacyModeEnabled: Boolean = false, + // Whether the user's privacy mode has been migrated from their project level settings (SweepConfig) + var hasPrivacyModeBeenUpdatedFromProject: Boolean = false, + // Whether to skip confirmation dialog when reverting changes + var skipRevertConfirmation: Boolean = false, + // Cache for allowed models from backend + var cachedModels: String? = null, + var cachedDefaultModel: String? = null, + // Whether the user has used ACTION_CHOOSE_LOOKUP_ITEM (pressed Enter on autocomplete) + var hasUsedLookupItem: Boolean = false, + var hasShownConfigureKeybindsForCmdKRequest: Boolean = false, + var hasShownConfigureKeybindsForCmdJRequest: Boolean = false, + // Map of tip hash to show count (to bias towards showing new tips and limit to 3 shows per tip) + var tipShowCounts: MutableMap = mutableMapOf(), + // Gateway onboarding flags + var hasShownGatewayClientOnboarding: Boolean = false, + var hasShownGatewayHostOnboarding: Boolean = false, + // Whether to show shortcut update notifications (true = don't show) + var dontShowShortcutNotifications: Boolean = false, + // Whether to show conflict plugin notifications (true = don't show) + var dontShowConflictNotifications: Boolean = false, + // Whether to show Cmd-J conflict notifications (true = don't show) + var dontShowCmdJConflictNotifications: Boolean = false, + // Whether the user has used the Review PR action before + var hasUsedReviewPRAction: Boolean = false, + // Whether the user has clicked the web search button + var hasClickedWebSearch: Boolean = false, + // TokenUsageIndicator tooltip hint state + // Whether we've ever shown the "(click to show details)" tooltip hint. + // Once true, we stop appending that hint to reduce tooltip noise. + var hasShownTokenUsageClickToShowDetailsHint: Boolean = false, + // Whether we've ever shown the "(click to hide details)" tooltip hint. + // Once true, we stop appending that hint to reduce tooltip noise. + var hasShownTokenUsageClickToHideDetailsHint: Boolean = false, + // List of favorite model display names for quick cycling + var favoriteModels: MutableList = mutableListOf(), + // Version of favorite models from backend, used to append new favorites when server version increases + var favoriteModelsVersion: Int = 0, + ) + + private var metaData = MetaData() + + override fun getState(): MetaData = metaData + + override fun loadState(state: MetaData) { + this.metaData = + state.copy( + finishedFilesCachePopulationList = state.finishedFilesCachePopulationList.toMutableList(), + lastIndexedFileList = state.lastIndexedFileList.toMutableList(), + favoriteModels = state.favoriteModels.toMutableList(), + ) + } + + var lastNotifiedVersion: String? + get() = metaData.lastNotifiedVersion + set(value) { + metaData.lastNotifiedVersion = value + } + + var historyButtonClicks: Int + get() = metaData.historyButtonClicks + set(value) { + metaData.historyButtonClicks = value + } + + var hasSeenTutorialV2: Boolean + get() = metaData.hasSeenTutorialV2 + set(value) { + metaData.hasSeenTutorialV2 = value + } + + var newButtonClicks: Int + get() = metaData.newButtonClicks + set(value) { + metaData.newButtonClicks = value + } + + var commitMessageButtonClicks: Int + get() = metaData.commitMessageButtonClicks + set(value) { + metaData.commitMessageButtonClicks = value + } + + var configButtonClicks: Int + get() = metaData.configButtonClicks + set(value) { + metaData.configButtonClicks = value + } + + var reportButtonClicks: Int + get() = metaData.reportButtonClicks + set(value) { + metaData.reportButtonClicks = value + } + + var applyButtonClicks: Int + get() = metaData.applyButtonClicks + set(value) { + metaData.applyButtonClicks = value + } + + var suggestedUserInputCount: Int + get() = metaData.suggestedUserInputCount + set(value) { + metaData.suggestedUserInputCount = value + } + + var acceptedSuggestedUserInputCount: Int + get() = metaData.acceptedSuggestedUserInputCount + set(value) { + metaData.acceptedSuggestedUserInputCount = value + } + + var rejectedSuggestedUserInputCount: Int + get() = metaData.rejectedSuggestedUserInputCount + set(value) { + metaData.rejectedSuggestedUserInputCount = value + } + + var chatWithSearch: Int + get() = metaData.chatWithSearch + set(value) { + metaData.chatWithSearch = value + } + + var chatWithoutSearch: Int + get() = metaData.chatWithoutSearch + set(value) { + metaData.chatWithoutSearch = value + } + + var fileContextUsageCount: Int + get() = metaData.fileContextUsageCount + set(value) { + metaData.fileContextUsageCount = value + } + + var chatsSent: Int + get() = metaData.chatsSent + set(value) { + metaData.chatsSent = value + } + + var projectFullSyncedList: List + get() = metaData.projectFullSyncedList + set(value) { + metaData.projectFullSyncedList = value + } + + var hasUsedFileShortcut: Boolean + get() = metaData.hasUsedFileShortcut + set(value) { + metaData.hasUsedFileShortcut = value + } + + var hasShownFileShortcutBalloon: Boolean + get() = metaData.hasShownFileShortcutBalloon + set(value) { + metaData.hasShownFileShortcutBalloon = value + } + + var hasShownNewChatBalloon: Boolean + get() = metaData.hasShownNewChatBalloon + set(value) { + metaData.hasShownNewChatBalloon = value + } + + var privacyModeEnabled: Boolean + get() = metaData.privacyModeEnabled + set(value) { + metaData.privacyModeEnabled = value + } + + var hasPrivacyModeBeenUpdatedFromProject: Boolean + get() = metaData.hasPrivacyModeBeenUpdatedFromProject + set(value) { + metaData.hasPrivacyModeBeenUpdatedFromProject = value + } + + var hasShownClickToAddFilesBalloon: Boolean + get() = metaData.hasShownClickToAddFilesBalloon + set(value) { + metaData.hasShownClickToAddFilesBalloon = value + } + + var chatHistoryUsed: Int + get() = metaData.chatHistoryUsed + set(value) { + metaData.chatHistoryUsed = value + } + + var chatHistoryBalloonWasShown: Boolean + get() = metaData.chatHistoryBalloonWasShown + set(value) { + metaData.chatHistoryBalloonWasShown = value + } + + var hasShownProblemsWindow: Boolean + get() = metaData.hasShownProblemsWindow + set(value) { + metaData.hasShownProblemsWindow = value + } + + var hasShownSearchPopup: Boolean + get() = metaData.hasShownSearchPopup + set(value) { + metaData.hasShownSearchPopup = value + } + + var hasShownAgentPopup: Boolean + get() = metaData.hasShownAgentPopup + set(value) { + metaData.hasShownAgentPopup = value + } + + var shownUpdateVersions: MutableList + get() = metaData.shownUpdateVersions + set(value) { + metaData.shownUpdateVersions = value + } + + var autocompleteAcceptCount: Int + get() = metaData.ghostTextTabAcceptCount + set(value) { + metaData.ghostTextTabAcceptCount = value + } + + var modelToggleUsed: Boolean + get() = metaData.modelToggleUsed + set(value) { + metaData.modelToggleUsed = value + } + + var chatModeToggleUsed: Boolean + get() = metaData.chatModeToggleUsed + set(value) { + metaData.chatModeToggleUsed = value + } + + var hasHandledPluginConflictsOnFirstInstall: Boolean + get() = metaData.hasHandledPluginConflictsOnFirstInstall + set(value) { + metaData.hasHandledPluginConflictsOnFirstInstall = value + } + + var hasSeenInstallationTelemetryEvent: Boolean + get() = metaData.hasSeenInstallationTelemetryEvent + set(value) { + metaData.hasSeenInstallationTelemetryEvent = value + } + + var isToolWindowVisible: Boolean + get() = metaData.isToolWindowVisible + set(value) { + metaData.isToolWindowVisible = value + } + + var skipRevertConfirmation: Boolean + get() = metaData.skipRevertConfirmation + set(value) { + metaData.skipRevertConfirmation = value + } + + var cachedModels: String? + get() = metaData.cachedModels + set(value) { + metaData.cachedModels = value + } + + var cachedDefaultModel: String? + get() = metaData.cachedDefaultModel + set(value) { + metaData.cachedDefaultModel = value + } + + var hasUsedLookupItem: Boolean + get() = metaData.hasUsedLookupItem + set(value) { + metaData.hasUsedLookupItem = value + } + + fun hasShownUpdateForVersion(version: String): Boolean = metaData.shownUpdateVersions.contains(version) + + fun markUpdateAsShown(version: String) { + if (!metaData.shownUpdateVersions.contains(version)) { + metaData.shownUpdateVersions.add(version) + } + } + + @Synchronized // Basic synchronization for list modification + fun isFilesCachePopulationFinishedForProject(projectHash: String): Boolean { + val entry = metaData.finishedFilesCachePopulationList.find { it.startsWith("${projectHash}_") } + return entry?.substringAfterLast('_')?.toBooleanStrictOrNull() ?: false + } + + @Synchronized + fun setFilesCachePopulationFinishedForProject( + projectHash: String, + finished: Boolean, + ) { + val prefix = "${projectHash}_" + val index = metaData.finishedFilesCachePopulationList.indexOfFirst { it.startsWith(prefix) } + val newValue = "$prefix$finished" + if (index != -1) { + metaData.finishedFilesCachePopulationList[index] = newValue + } else { + metaData.finishedFilesCachePopulationList.add(newValue) + } + } + + @Synchronized + fun getLastIndexedFileForProject(projectHash: String): Int { + val entry = metaData.lastIndexedFileList.find { it.startsWith("${projectHash}_") } + return entry?.substringAfterLast('_')?.toIntOrNull() ?: 0 + } + + @Synchronized + fun setLastIndexedFileForProject( + projectHash: String, + index: Int, + ) { + val prefix = "${projectHash}_" + val listIndex = metaData.lastIndexedFileList.indexOfFirst { it.startsWith(prefix) } + val newValue = "$prefix$index" + if (listIndex != -1) { + metaData.lastIndexedFileList[listIndex] = newValue + } else { + metaData.lastIndexedFileList.add(newValue) + } + } + + @Synchronized + fun isEntitiesCachePopulationFinishedForProject(projectHash: String): Boolean { + val entry = metaData.finishedEntitiesCachePopulationList.find { it.startsWith("${projectHash}_") } + return entry?.substringAfterLast('_')?.toBooleanStrictOrNull() ?: false + } + + @Synchronized + fun setEntitiesCachePopulationFinishedForProject( + projectHash: String, + finished: Boolean, + ) { + val prefix = "${projectHash}_" + val index = metaData.finishedEntitiesCachePopulationList.indexOfFirst { it.startsWith(prefix) } + val newValue = "$prefix$finished" + if (index != -1) { + metaData.finishedEntitiesCachePopulationList[index] = newValue + } else { + metaData.finishedEntitiesCachePopulationList.add(newValue) + } + } + + @Synchronized + fun getLastIndexedEntityFileForProject(projectHash: String): Int { + val entry = metaData.lastIndexedEntityFileList.find { it.startsWith("${projectHash}_") } + return entry?.substringAfterLast('_')?.toIntOrNull() ?: 0 + } + + @Synchronized + fun setLastIndexedEntityFileForProject( + projectHash: String, + index: Int, + ) { + val prefix = "${projectHash}_" + val listIndex = metaData.lastIndexedEntityFileList.indexOfFirst { it.startsWith(prefix) } + val newValue = "$prefix$index" + if (listIndex != -1) { + metaData.lastIndexedEntityFileList[listIndex] = newValue + } else { + metaData.lastIndexedEntityFileList.add(newValue) + } + } + + @Synchronized + fun getLastKnownFileCountForProject(projectHash: String): Int { + val entry = metaData.lastKnownFileCountList.find { it.startsWith("${projectHash}_") } + return entry?.substringAfterLast('_')?.toIntOrNull() ?: 0 + } + + @Synchronized + fun setLastKnownFileCountForProject( + projectHash: String, + count: Int, + ) { + val prefix = "${projectHash}_" + val listIndex = metaData.lastKnownFileCountList.indexOfFirst { it.startsWith(prefix) } + val newValue = "$prefix$count" + if (listIndex != -1) { + metaData.lastKnownFileCountList[listIndex] = newValue + } else { + metaData.lastKnownFileCountList.add(newValue) + } + } + + @Synchronized + fun getDefaultBranchForProject(projectHash: String): String? { + val entry = metaData.defaultBranchListForFileAutocomplete.find { it.startsWith("${projectHash}_") } + return entry?.substringAfterLast('_') + } + + @Synchronized + fun setDefaultBranchForProject( + projectHash: String, + branchName: String, + ) { + val prefix = "${projectHash}_" + val index = metaData.defaultBranchListForFileAutocomplete.indexOfFirst { it.startsWith(prefix) } + val newValue = "$prefix$branchName" + if (index != -1) { + metaData.defaultBranchListForFileAutocomplete[index] = newValue + } else { + metaData.defaultBranchListForFileAutocomplete.add(newValue) + } + } + + @Synchronized + fun resetSweepCache(projectHash: String) { + // Completely wipe all metadata by resetting to default state + metaData = MetaData() + } + + @Synchronized + fun getTipShowCount(tipHash: Int): Int = metaData.tipShowCounts[tipHash] ?: 0 + + @Synchronized + fun incrementTipShowCount(tipHash: Int) { + val currentCount = metaData.tipShowCounts[tipHash] ?: 0 + metaData.tipShowCounts[tipHash] = currentCount + 1 + } + + var hasShownGatewayClientOnboarding: Boolean + get() = metaData.hasShownGatewayClientOnboarding + set(value) { + metaData.hasShownGatewayClientOnboarding = value + } + + var hasShownGatewayHostOnboarding: Boolean + get() = metaData.hasShownGatewayHostOnboarding + set(value) { + metaData.hasShownGatewayHostOnboarding = value + } + + var hasSeenChatTutorial: Boolean + get() = metaData.hasSeenChatTutorial + set(value) { + metaData.hasSeenChatTutorial = value + } + + var dontShowShortcutNotifications: Boolean + get() = metaData.dontShowShortcutNotifications + set(value) { + metaData.dontShowShortcutNotifications = value + } + + var dontShowConflictNotifications: Boolean + get() = metaData.dontShowConflictNotifications + set(value) { + metaData.dontShowConflictNotifications = value + } + + var dontShowCmdJConflictNotifications: Boolean + get() = metaData.dontShowCmdJConflictNotifications + set(value) { + metaData.dontShowCmdJConflictNotifications = value + } + + var hasUsedReviewPRAction: Boolean + get() = metaData.hasUsedReviewPRAction + set(value) { + metaData.hasUsedReviewPRAction = value + } + + var hasClickedWebSearch: Boolean + get() = metaData.hasClickedWebSearch + set(value) { + metaData.hasClickedWebSearch = value + } + + var hasShownTokenUsageClickToShowDetailsHint: Boolean + get() = metaData.hasShownTokenUsageClickToShowDetailsHint + set(value) { + metaData.hasShownTokenUsageClickToShowDetailsHint = value + } + + var hasShownTokenUsageClickToHideDetailsHint: Boolean + get() = metaData.hasShownTokenUsageClickToHideDetailsHint + set(value) { + metaData.hasShownTokenUsageClickToHideDetailsHint = value + } + + var favoriteModels: MutableList + get() = metaData.favoriteModels + set(value) { + metaData.favoriteModels = value + } + + var favoriteModelsVersion: Int + get() = metaData.favoriteModelsVersion + set(value) { + metaData.favoriteModelsVersion = value + } + + companion object { + fun getInstance(): OxideCodeMetaData = ApplicationManager.getApplication().getService(OxideCodeMetaData::class.java) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeSettings.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeSettings.kt new file mode 100644 index 0000000..fe368a0 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeSettings.kt @@ -0,0 +1,241 @@ +package com.oxidecode.settings + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.project.Project +import com.intellij.util.messages.Topic +import com.intellij.util.xmlb.XmlSerializerUtil + +data class CustomPrompt( + var name: String = "", + var prompt: String = "", + var includeSelectedCode: Boolean = true, +) + +data class BYOKProviderConfig( + var apiKey: String = "", + var eligibleModels: List = emptyList(), +) + +@State( + name = "com.oxidecode.settings.OxideCodeSettings", + storages = [Storage("OxideCodeSettings.xml")], +) +class OxideCodeSettings : PersistentStateComponent { + companion object { + private const val DEFAULT_SWEEP_URL = "" + private const val DEFAULT_BETA_FLAG_ON = false + private const val DEFAULT_NEXT_EDIT_PREDICTION_ON = true + private const val DEFAULT_ACCEPT_WORD_ON_RIGHT_ARROW = true + private const val DEFAULT_ANTHROPIC_API_KEY = "" + private const val DEFAULT_PLAY_NOTIFICATION_ON_STREAM_END = false + private const val DEFAULT_DEVELOPER_MODE_ON = false + + // -1L means "unset" so project-level values can migrate in + private const val DEFAULT_AUTOCOMPLETE_DEBOUNCE_MS = -1L + + // Default to false - do not automatically disable conflicting autocomplete plugins + private const val DEFAULT_DISABLE_CONFLICTING_PLUGINS = true + + fun getInstance(): OxideCodeSettings = ApplicationManager.getApplication().getService(OxideCodeSettings::class.java) + } + + // Do not notify settings changed on each save, fire it in config instead + fun interface SettingsChangedNotifier { + fun settingsChanged() + + companion object { + @JvmField + val TOPIC = Topic.create("Sweep settings changed", SettingsChangedNotifier::class.java) + } + } + + + var baseUrl: String = DEFAULT_SWEEP_URL + get() = field.trim().trimEnd('/') + set(value) { + if (value != field) { + field = value + notifySettingsChanged() + } else { + field = value + } + } + + var betaFlagOn: Boolean = DEFAULT_BETA_FLAG_ON + set(value) { + field = value + } + + var nextEditPredictionFlagOn: Boolean = + DEFAULT_NEXT_EDIT_PREDICTION_ON + set(value) { + if (value != field) { + field = value + notifySettingsChanged() + } else { + field = value + } + } + + var acceptWordOnRightArrow: Boolean = + DEFAULT_ACCEPT_WORD_ON_RIGHT_ARROW + set(value) { + if (value != field) { + field = value + notifySettingsChanged() + } else { + field = value + } + } + + var anthropicApiKey: String = DEFAULT_ANTHROPIC_API_KEY + get() = field.trim() + set(value) { + field = value + } + + var playNotificationOnStreamEnd: Boolean = + DEFAULT_PLAY_NOTIFICATION_ON_STREAM_END + set(value) { + field = value + } + + var developerModeOn: Boolean = DEFAULT_DEVELOPER_MODE_ON + set(value) { + field = value + } + + /** + * Autocomplete debounce delay in milliseconds. + * This is stored at the application level and applies to all projects. + * A value of -1 indicates "unset" and allows a one-time migration from any existing + * project-level setting in SweepConfig when first accessed. + */ + var autocompleteDebounceMs: Long = + DEFAULT_AUTOCOMPLETE_DEBOUNCE_MS + set(value) { + val clamped = value.coerceIn(10L, 1000L) + field = clamped + // We intentionally do not fire notifySettingsChanged here to avoid + // excessive message bus chatter while the user drags the slider. + } + + /** + * Automatically disable conflicting autocomplete plugins. + * This is stored at the application level and applies to all projects. + */ + var disableConflictingPlugins: Boolean = + DEFAULT_DISABLE_CONFLICTING_PLUGINS + set(value) { + if (value != field) { + field = value + notifySettingsChanged() + } else { + field = value + } + } + + var customPrompts: MutableList = mutableListOf() + set(value) { + field = value + notifySettingsChanged() + } + + var hasInitializedDefaultPrompts: Boolean = false + + /** + * BYOK (Bring Your Own Key) provider configurations. + * This is stored at the application level and applies to all projects. + * Map of provider name -> BYOKProviderConfig (apiKey, eligibleModels) + */ + var byokProviderConfigs: MutableMap = mutableMapOf() + set(value) { + field = value + // Don't notify settings changed for BYOK to avoid excessive chatter + } + + var autocompleteLocalMode: Boolean = false + + var autocompleteLocalPort: Int = 8081 + + fun ensureDefaultPromptsInitialized() { + var addedPrompt = false + + if (customPrompts.none { it.name == "AI Code Review" }) { + customPrompts.add( + CustomPrompt( + name = "AI Code Review", + prompt = "Review each of the changes in detail for potential bugs", + includeSelectedCode = false, + ), + ) + addedPrompt = true + } + + if (customPrompts.none { it.name == "Explain Code" }) { + customPrompts.add( + CustomPrompt( + name = "Explain Code", + prompt = "Explain what the code does.", + includeSelectedCode = true, + ), + ) + addedPrompt = true + } + + if (customPrompts.none { it.name == "Write Documentation" }) { + customPrompts.add( + CustomPrompt( + name = "Write Documentation", + prompt = "Please write documentation for the highlighted code.", + includeSelectedCode = true, + ), + ) + addedPrompt = true + } + + if (addedPrompt) { + // Trigger state save by creating a new list instance to change the reference + customPrompts = customPrompts.toMutableList() + } + + if (!hasInitializedDefaultPrompts || addedPrompt) { + hasInitializedDefaultPrompts = true + } + } + + fun notifySettingsChanged() { + ApplicationManager.getApplication().invokeLater { + ApplicationManager + .getApplication() + ?.messageBus + ?.syncPublisher(SettingsChangedNotifier.TOPIC) + ?.settingsChanged() + } + } + + fun runNowAndOnSettingsChange( + project: Project, + parentDisposable: Disposable, + callback: OxideCodeSettings.() -> Unit, + ) { + this.callback() + project.messageBus.connect(parentDisposable).subscribe( + SettingsChangedNotifier.TOPIC, + SettingsChangedNotifier { + getInstance().callback() + }, + ) + } + + override fun getState(): OxideCodeSettings = this + + override fun loadState(state: OxideCodeSettings) { + XmlSerializerUtil.copyBean(state, this) + ensureDefaultPromptsInitialized() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/EditorThemeManager.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/EditorThemeManager.kt new file mode 100644 index 0000000..f76ea11 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/EditorThemeManager.kt @@ -0,0 +1,283 @@ +package com.oxidecode.theme + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.colors.impl.EditorColorsSchemeImpl +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.markup.TextAttributes +import com.oxidecode.theme.OxideCodeIcons.brighter +import com.oxidecode.theme.OxideCodeIcons.darker +import com.oxidecode.utils.isIDEDarkMode +import com.oxidecode.views.RoundedButton +import java.awt.Container +import javax.swing.UIManager + +class EditorThemeManager( + private val editor: EditorEx, +) { + fun applyDarkenedTheme() { + if (editor.isDisposed) return + // Create a new color scheme to avoid modifying the global one + val colorsScheme = EditorColorsManager.getInstance().globalScheme + val newScheme = (colorsScheme as EditorColorsSchemeImpl).clone() as EditorColorsSchemeImpl + val currentBackground = colorsScheme.defaultBackground + + with(newScheme) { + // Set background colors + setColor(EditorColors.READONLY_BACKGROUND_COLOR, currentBackground) + setColor(EditorColors.GUTTER_BACKGROUND, currentBackground) + setColor(EditorColors.EDITOR_GUTTER_BACKGROUND, currentBackground) + + // Darken each syntax highlighting element individually + setAttributes( + HighlighterColors.TEXT, + darkenAttributes(colorsScheme.getAttributes(HighlighterColors.TEXT)), + ) + + // Keywords and identifiers + setAttributes( + DefaultLanguageHighlighterColors.KEYWORD, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.KEYWORD)), + ) + setAttributes( + DefaultLanguageHighlighterColors.IDENTIFIER, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.IDENTIFIER)), + ) + + // Literals + setAttributes( + DefaultLanguageHighlighterColors.NUMBER, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.NUMBER)), + ) + setAttributes( + DefaultLanguageHighlighterColors.STRING, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.STRING)), + ) + setAttributes( + DefaultLanguageHighlighterColors.VALID_STRING_ESCAPE, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.VALID_STRING_ESCAPE)), + ) + + // Comments + setAttributes( + DefaultLanguageHighlighterColors.LINE_COMMENT, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.LINE_COMMENT)), + ) + setAttributes( + DefaultLanguageHighlighterColors.BLOCK_COMMENT, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.BLOCK_COMMENT)), + ) + setAttributes( + DefaultLanguageHighlighterColors.DOC_COMMENT, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.DOC_COMMENT)), + ) + + // Operators and punctuation + setAttributes( + DefaultLanguageHighlighterColors.OPERATION_SIGN, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.OPERATION_SIGN)), + ) + setAttributes( + DefaultLanguageHighlighterColors.PARENTHESES, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.PARENTHESES)), + ) + setAttributes( + DefaultLanguageHighlighterColors.BRACKETS, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.BRACKETS)), + ) + setAttributes( + DefaultLanguageHighlighterColors.BRACES, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.BRACES)), + ) + setAttributes( + DefaultLanguageHighlighterColors.DOT, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.DOT)), + ) + setAttributes( + DefaultLanguageHighlighterColors.COMMA, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.COMMA)), + ) + setAttributes( + DefaultLanguageHighlighterColors.SEMICOLON, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.SEMICOLON)), + ) + + // Functions and variables + setAttributes( + DefaultLanguageHighlighterColors.FUNCTION_DECLARATION, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.FUNCTION_DECLARATION)), + ) + setAttributes( + DefaultLanguageHighlighterColors.FUNCTION_CALL, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.FUNCTION_CALL)), + ) + setAttributes( + DefaultLanguageHighlighterColors.LOCAL_VARIABLE, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.LOCAL_VARIABLE)), + ) + setAttributes( + DefaultLanguageHighlighterColors.GLOBAL_VARIABLE, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.GLOBAL_VARIABLE)), + ) + setAttributes( + DefaultLanguageHighlighterColors.PARAMETER, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.PARAMETER)), + ) + + // Classes and interfaces + setAttributes( + DefaultLanguageHighlighterColors.CLASS_NAME, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.CLASS_NAME)), + ) + setAttributes( + DefaultLanguageHighlighterColors.INTERFACE_NAME, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.INTERFACE_NAME)), + ) + setAttributes( + DefaultLanguageHighlighterColors.CLASS_REFERENCE, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.CLASS_REFERENCE)), + ) + + // Instance and static members + setAttributes( + DefaultLanguageHighlighterColors.INSTANCE_METHOD, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.INSTANCE_METHOD)), + ) + setAttributes( + DefaultLanguageHighlighterColors.INSTANCE_FIELD, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.INSTANCE_FIELD)), + ) + setAttributes( + DefaultLanguageHighlighterColors.STATIC_METHOD, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.STATIC_METHOD)), + ) + setAttributes( + DefaultLanguageHighlighterColors.STATIC_FIELD, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.STATIC_FIELD)), + ) + + // Metadata and markup + setAttributes( + DefaultLanguageHighlighterColors.METADATA, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.METADATA)), + ) + setAttributes( + DefaultLanguageHighlighterColors.MARKUP_TAG, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.MARKUP_TAG)), + ) + setAttributes( + DefaultLanguageHighlighterColors.MARKUP_ATTRIBUTE, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.MARKUP_ATTRIBUTE)), + ) + setAttributes( + DefaultLanguageHighlighterColors.MARKUP_ENTITY, + darkenAttributes(colorsScheme.getAttributes(DefaultLanguageHighlighterColors.MARKUP_ENTITY)), + ) + } + + editor.colorsScheme = newScheme + removeErrorTheming() + } + + fun revertTheme() { + if (editor.isDisposed) return + editor.colorsScheme = EditorColorsManager.getInstance().schemeForCurrentUITheme + removeErrorTheming() + } + + fun removeErrorTheming() { + if (editor.isDisposed) return + val scheme = + editor.colorsScheme.clone() as? EditorColorsScheme + ?: EditorColorsManager.getInstance().schemeForCurrentUITheme.clone() as EditorColorsScheme + listOf( + CodeInsightColors.ERRORS_ATTRIBUTES, + CodeInsightColors.WARNINGS_ATTRIBUTES, + CodeInsightColors.WEAK_WARNING_ATTRIBUTES, + CodeInsightColors.NOT_USED_ELEMENT_ATTRIBUTES, + CodeInsightColors.DEPRECATED_ATTRIBUTES, + CodeInsightColors.MARKED_FOR_REMOVAL_ATTRIBUTES, + CodeInsightColors.GENERIC_SERVER_ERROR_OR_WARNING, + CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES, + CodeInsightColors.UNMATCHED_BRACE_ATTRIBUTES, + ).forEach { key -> + val origAttrs = scheme.getAttributes(key) ?: return@forEach + val newAttrs = origAttrs.clone() + newAttrs.effectType = null + newAttrs.effectColor = null + newAttrs.foregroundColor = scheme.defaultForeground + scheme.setAttributes(key, newAttrs) + } + + editor.colorsScheme = scheme + } + + fun darkenContainer(container: Container?) { + if (container == null) return + ApplicationManager.getApplication().invokeLater { + if (editor.isDisposed) return@invokeLater + for (child in container.components) { + child.foreground = child.foreground.withAlpha(0.5f) + if (child is RoundedButton) { + child.icon?.let { currentIcon -> + // Cache original icon if not already cached + val originalIcon = + child.getClientProperty("sweep.originalIcon") as? javax.swing.Icon + ?: currentIcon.also { child.putClientProperty("sweep.originalIcon", it) } + + // Apply darkening/brightening to the original icon, not the current one + child.icon = + if (isIDEDarkMode()) { + originalIcon.darker(5) + } else { + originalIcon.brighter(5) + } + } + } + + // Recursively handle nested containers + if (child is Container) { + darkenContainer(child) + } + } + } + } + + fun revertContainer(container: Container?) { + if (container == null) return + ApplicationManager.getApplication().invokeLater { + if (editor.isDisposed) return@invokeLater + for (child in container.components) { + child.foreground = UIManager.getColor("Panel.foreground") + if (child is RoundedButton) { + // Restore the original icon instead of brightening the current one + val originalIcon = child.getClientProperty("sweep.originalIcon") as? javax.swing.Icon + if (originalIcon != null) { + child.icon = originalIcon + } + } + // Recursively revert nested containers + if (child is Container) { + revertContainer(child) + } + } + } + } + + private fun darkenAttributes(original: TextAttributes?): TextAttributes = + TextAttributes().apply { + foregroundColor = original?.foregroundColor?.withAlpha(0.6f) + ?: editor.colorsScheme.defaultForeground.withAlpha(0.6f) + backgroundColor = editor.colorsScheme.defaultBackground + original?.let { + fontType = it.fontType + effectType = it.effectType + effectColor = it.effectColor?.withAlpha(0.6f) + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeColors.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeColors.kt new file mode 100644 index 0000000..e976bc2 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeColors.kt @@ -0,0 +1,341 @@ +package com.oxidecode.theme + +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.ui.ColorUtil +import com.intellij.ui.Gray +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import com.oxidecode.utils.customBrighter +import com.oxidecode.utils.customDarker +import com.oxidecode.utils.withLightMode +import java.awt.Color + +fun Color.withAlpha(alpha: Float) = Color(red, green, blue, (255 * alpha).toInt()) + +fun Color.withAlpha(alpha: Int) = Color(red, green, blue, alpha) + +fun JBColor.withAlpha(alpha: Float) = + JBColor( + (this as Color).withAlpha(alpha), + (this as Color).withAlpha(alpha), + ) + +fun JBColor.withAlpha(alpha: Int) = + JBColor( + (this as Color).withAlpha(alpha), + (this as Color).withAlpha(alpha), + ) + +fun JBColor.withAlpha( + lightAlpha: Float, + darkAlpha: Float, +) = JBColor( + (this as Color).withAlpha(lightAlpha), + (this as Color).withAlpha(darkAlpha), +) + +fun Color.withAlpha( + lightAlpha: Float, + darkAlpha: Float, +) = JBColor( + this.withAlpha(lightAlpha), + this.withAlpha(darkAlpha), +) + +operator fun Color.plus(other: Color) = + Color( + (this.red + other.red) / 2, + (this.green + other.green) / 2, + (this.blue + other.blue) / 2, + (this.alpha + other.alpha) / 2, + ) + +object OxideCodeColors { + const val HOVER_COLOR_FACTOR = 0.9 + + val transparent = Color(0, 0, 0, 0) + val acceptedGlowColor = Color(117, 197, 144, 31) + val acceptedHighlightColor = Color(117, 197, 144, 31) + + val whitespaceHighlightColor = Color(87, 255, 137, (255 * 0.06).toInt()) + + val additionHighlightColor = Color(87, 255, 137, (255 * 0.14).toInt()) + val deletionHighlightColor get() = Color(255, 86, 91, (255 * 0.18).toInt()) + + private fun calculateDynamicAlpha(): Int { + // Calculate brightness of background (0-255 scale) + // Dark -> 70 + // Darcula -> 71 + // Deep Ocean -> 79 + // High contrast -> 90 + val brightness = (backgroundColor.red * 0.299 + backgroundColor.green * 0.587 + backgroundColor.blue * 0.114).toInt() + return (90 - brightness).coerceIn(70, 90) + } + + val borderColor: Color + get() = JBUI.CurrentTheme.Popup.borderColor(false) + + val activeBorderColor: JBColor + get() = + JBColor( + borderColor.darker().withAlpha(0.2f), + borderColor + .brighter() + .brighter() + .brighter() + .withAlpha(0.2f), + ) + + // Subtle border for file labels using theme colors + val fileLabelBorder get() = + JBUI.CurrentTheme.Popup + .borderColor(false) + .darker() + + val semanticColors = + listOf( + JBColor( + Color(82, 122, 190), + Color(73, 113, 181), + ), + JBColor( + Color(190, 112, 112), + Color(181, 103, 103), + ), + JBColor( + Color(61, 118, 118), + Color(52, 109, 109), + ), + JBColor( + Color(190, 153, 112), + Color(181, 144, 103), + ), + JBColor( + Color(157, 82, 124), + Color(148, 73, 115), + ), + ) + + // Background color for UI elements + val backgroundColor: Color + get() = JBColor.background().darker().withLightMode() + + // Light grey background color for chat and user message components + val chatAndUserMessageBackground: JBColor + get() = + JBColor( + Gray._253, // Light mode: very light grey + Gray._27, // Dark mode: darker than tool window, lighter than editor + ) + + // Dynamic property for active explanation block background + val activeExplanationBlockBackgroundColor: JBColor + get() = + JBColor( + Color.BLACK.withAlpha(0.05f), + Color.WHITE.withAlpha(0.05f), + ) + + // Background color for inactive components - slightly darker than regular background + val inactiveExplanationBlockBackgroundColor: JBColor + get() = + JBColor( + backgroundColor.customDarker(0.1f), + backgroundColor.customBrighter(0.05f), + ) + + // Dynamic tool window background color + val toolWindowBackgroundColor: Color + get() = JBColor.background() + + // Foreground color for text + val foregroundColor: Color + get() = JBColor.foreground() + + // Editor's default foreground color hex + val editorForegroundColorHex get() = + String.format( + "%06x", + EditorColorsManager + .getInstance() + .globalScheme.defaultForeground.rgb and 0xFFFFFF, + ) + + // Editor's default background color hex + val editorBackgroundColorHex get() = + String.format( + "%06x", + EditorColorsManager + .getInstance() + .globalScheme.defaultBackground.rgb and 0xFFFFFF, + ) + + val streamingColor = + JBColor( + Color(128, 128, 128, 50), + Color(200, 200, 200, 50), + ) + + // Hex string representation of foreground color (without alpha) + val foregroundColorHex get() = String.format("%06x", foregroundColor.rgb and 0xFFFFFF) + + val fileLabelBorderHex get() = String.format("%06x", fileLabelBorder.rgb and 0xFFFFFF) + + // Hex string representation of background color (without alpha) + val backgroundColorHex get() = String.format("%06x", backgroundColor.rgb and 0xFFFFFF) + + // Background color for inline code blocks + val codeBackgroundColor get() = backgroundColor.darker() + + // Hacky fix for High contrast but works + val hoverableBackgroundColor get() = + if (backgroundColor == Color.BLACK) { + JBColor( + Color(64, 64, 64), + Color(64, 64, 64), + ) + } else { + backgroundColor + } + + // Hex string representation of code background color (without alpha) + val codeExplanationDisplayTextColor get() = "D1A8FE" + + // Send button color + val sendButtonColor: JBColor + get() = + JBColor( + ColorUtil.fromHex("#e8e6e6"), + ColorUtil.fromHex("#414244"), + ) + + // Send button foreground color + val sendButtonColorForeground: JBColor + get() = + JBColor( + Gray._0, + Gray._255, + ) + + // Ask mode blue colors (subtle blue) + val askModeTextColor: JBColor + get() = + JBColor( + Color(60, 130, 215), // Light mode: #3C82D7 (softer blue) + Color(90, 150, 225), // Dark mode: #5A96E1 (softer blue) + ) + + // Ask mode semi-transparent background + private val askModeBackgroundColor: JBColor + get() = + JBColor( + Color(60, 130, 215, 20), // Light mode + Color(90, 150, 225, 12), // Dark mode: blue with ~5% opacity + ) + + // Helper function to get mode-specific background color + fun getModeBackgroundColor(mode: String): JBColor = + when (mode.lowercase()) { + "ask" -> askModeBackgroundColor + "agent" -> sendButtonColor + else -> sendButtonColor + } + + // Helper function to get mode-specific text color + fun getModeTextColor(mode: String): JBColor = + when (mode.lowercase()) { + "ask" -> askModeTextColor + "agent" -> sendButtonColorForeground + else -> sendButtonColorForeground + } + + // Helper function to get mode-specific hover color + fun getModeHoverColor(mode: String): JBColor { + // Always use default gray hover color regardless of mode + return createHoverColor(backgroundColor) + } + + // Code block border color - static as it doesn't change with theme + val codeBlockBorderColor: Color = ColorUtil.fromHex("#48494b") + + // Sweep rules accent color - static as it's a brand color + val sweepRulesAccentColor: JBColor = JBColor(Color(88, 157, 246), Color(104, 159, 244)) + + // Dropdown panel background based on current background + val sweepDropdownPanelBackground: JBColor + get() = + JBColor( + Gray._255, + (backgroundColor.brighter() + backgroundColor), + ) + + val tooltipBackgroundColor = JBColor(0x7B9ADB, 0x486AA9) + + val listItemSelectionBackGround = JBColor(Color(172, 173, 175), Color(33, 35, 38)) + + val backgroundTransparentColor = + JBColor( + Color(0, 0, 0, 0), + Color(0, 0, 0, 0), + ) + + // GitHub button colors that adapt to light/dark themes + val githubColor = JBColor(Color(36, 41, 47), Color(36, 41, 47)) // GitHub's brand color + val textOnPrimary = JBColor(Color(255, 255, 255), Color(255, 255, 255)) // White text regardless of theme + + // Primary button blue color - used for accept buttons and other primary actions + val primaryButtonColor = JBColor(Color(52, 116, 240, 255), Color(52, 116, 240, 255)) + val loginButtonColor = JBColor(Color(33, 150, 243, 255), Color(33, 150, 243, 255)) + + // Subtle grey color for UI elements like token usage indicator and copy button + val subtleGreyColor = JBColor(0x6E6E6E, 0x5A5D61) + + // Planning mode indicator text color + val planningModeTextColor = primaryButtonColor + + // Blended text color for reasoning blocks and other subtle text (80% opacity blend) + // This creates a softer text appearance by blending foreground with background + val blendedTextColor: Color + get() { + val opacity = 0.8f + return Color( + (foregroundColor.red * opacity + backgroundColor.red * (1 - opacity)).toInt(), + (foregroundColor.green * opacity + backgroundColor.green * (1 - opacity)).toInt(), + (foregroundColor.blue * opacity + backgroundColor.blue * (1 - opacity)).toInt(), + ) + } + + fun refreshColors() { + // This method is now deprecated since all colors are dynamically computed. + // Kept for backward compatibility but does nothing. + // Components will automatically get updated colors through the dynamic properties. + } + + fun colorToHex(color: Color): String = String.format("#%02x%02x%02x", color.red, color.green, color.blue) + + /** + * Creates a hover effect color based on the background color + */ + fun createHoverColor(background: Color): JBColor = + JBColor( + Color( + (background.getRed() * HOVER_COLOR_FACTOR).toInt(), + (background.getGreen() * HOVER_COLOR_FACTOR).toInt(), + (background.getBlue() * HOVER_COLOR_FACTOR).toInt(), + ), + background.customBrighter(0.15f), + ) + + fun createHoverColor( + background: Color, + factor: Float = 0.1f, + ): JBColor = + JBColor( + Color( + (background.red * (1 - factor)).toInt().coerceIn(0, 255), + (background.green * (1 - factor)).toInt().coerceIn(0, 255), + (background.blue * (1 - factor)).toInt().coerceIn(0, 255), + ), + background.customBrighter(factor), + ) +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeIcons.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeIcons.kt new file mode 100644 index 0000000..675934e --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeIcons.kt @@ -0,0 +1,48 @@ +package com.oxidecode.theme + +import com.intellij.icons.AllIcons +import com.intellij.openapi.util.IconLoader +import com.intellij.util.IconUtil +import javax.swing.Icon + +object OxideCodeIcons { + private fun loadIcon(path: String): Icon = IconLoader.getIcon(path, OxideCodeIcons::class.java) + + val SweepIcon get() = loadIcon("/icons/sweep13x13.svg") + val ChevronUp get() = loadIcon("/icons/chevronUp.svg") + val ChevronDown get() = loadIcon("/icons/chevronDown.svg") + val Close get() = loadIcon("/icons/close.svg") + val SearchIcon get() = loadIcon("/icons/search_files_icon.svg") + val ReadFileIcon get() = loadIcon("/icons/read_file_icon.svg") + val EditIcon get() = IconLoader.getIcon("/icons/edit_icon.svg", OxideCodeIcons::class.java) + val PlayIcon get() = AllIcons.Actions.Execute + + object FileType { + val Python get() = loadIcon("/icons/python.svg") + + val Kotlin get() = loadIcon("/icons/kotlin.svg") + + val Cpp get() = loadIcon("/icons/cpp.svg") + + val Scala get() = loadIcon("/icons/scala.svg") + + val Rust get() = loadIcon("/icons/rust.svg") + + val Go get() = loadIcon("/icons/go.svg") + + val Csv get() = loadIcon("/icons/csv.svg") + + val GitIgnore get() = loadIcon("/icons/gitignore.svg") + + val Executable get() = loadIcon("/icons/terminal.svg") + + val Typescript get() = loadIcon("/icons/typescript.svg") + } + + fun Icon.scale(targetSize: Float): Icon = IconUtil.scale(this, null, targetSize / iconWidth.toFloat()) + + fun Icon.darker(factor: Int = 2): Icon = IconUtil.darker(this, factor) + + fun Icon.brighter(factor: Int = 2): Icon = IconUtil.brighter(this, factor) + +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/RoundedHighlightPainter.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/RoundedHighlightPainter.kt new file mode 100644 index 0000000..4c61675 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/RoundedHighlightPainter.kt @@ -0,0 +1,47 @@ +package com.oxidecode.theme + +import java.awt.Color +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.Shape +import java.awt.geom.RoundRectangle2D +import javax.swing.text.DefaultHighlighter +import javax.swing.text.JTextComponent +import javax.swing.text.Position +import javax.swing.text.View + +class RoundedHighlightPainter( + color: Color, +) : DefaultHighlighter.DefaultHighlightPainter(color) { + override fun paintLayer( + g: Graphics?, + offs0: Int, + offs1: Int, + bounds: Shape?, + c: JTextComponent?, + view: View?, + ): Shape? { + if (c == null || view == null || g == null) return null + + val g2d = g as Graphics2D + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + val start = view.modelToView(offs0, Position.Bias.Forward, offs1, Position.Bias.Backward, bounds).bounds + val end = view.modelToView(offs1 - 1, Position.Bias.Forward, offs1, Position.Bias.Backward, bounds).bounds + + val roundRect = + RoundRectangle2D.Float( + start.x.toFloat(), + start.y.toFloat(), + (end.x - start.x + end.width).toFloat(), + start.height.toFloat(), + 6f, + 6f, + ) + + g2d.color = color + g2d.fill(roundRect) + return roundRect + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/ActionUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/ActionUtils.kt new file mode 100644 index 0000000..dba405b --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/ActionUtils.kt @@ -0,0 +1,89 @@ +package com.oxidecode.utils + +import com.intellij.ide.actions.ShowSettingsUtilImpl +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ExecutionDataKeys +import com.intellij.openapi.actionSystem.KeyboardShortcut +import com.intellij.openapi.editor.event.EditorMouseEvent +import com.intellij.openapi.keymap.KeymapManager +import com.intellij.openapi.keymap.impl.ui.EditKeymapsDialog +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import javax.swing.KeyStroke + +fun isTerminalContext(e: AnActionEvent): Boolean { + val env = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) + return env != null +} + +fun isTerminalEditor(e: EditorMouseEvent): Boolean = + e.editor.virtualFile + ?.fileType + ?.name == null + +fun isValidSelection(text: String?): Boolean { + if (text.isNullOrBlank()) return false + + val trimmed = text.trim() + // Check if it's a meaningful selection: + // - Contains at least one word character + return trimmed.any { it.isLetterOrDigit() } +} + +fun getKeyStrokesForAction(actionId: String): List { + val keymap = KeymapManager.getInstance().activeKeymap + return keymap + .getShortcuts(actionId) + .asSequence() + .filterIsInstance() + .flatMap { sequenceOf(it.firstKeyStroke, it.secondKeyStroke) } + .filterNotNull() + .toList() +} + +fun parseKeyStrokesToPrint(k: KeyStroke?): String? { + if (k == null) return null + return k + .toString() + .replace("pressed ", "") + .replace("meta", "⌘") + .replace("control", "Ctrl") + .replace("ctrl", "Ctrl") + .replace("alt", if (SystemInfo.isMac) "⌥" else "Alt") + .replace("shift", if (SystemInfo.isMac) "⇧" else "Shift") + .replace("BACK_SPACE", "⌫") + .replace("ENTER", "⏎") + .replace(" ", if (SystemInfo.isMac) "" else "+") +} + +fun getActionText(actionId: String): String { + val action = ActionManager.getInstance().getAction(actionId) + return action?.templateText ?: actionId +} + +/** + * Opens the keymap settings dialog for a specific action. + * Attempts to open the EditKeymapsDialog twice (as it may fail on first attempt), + * and falls back to the general keymap settings if both attempts fail. + * + * @param project The current project + * @param actionId The ID of the action to configure + */ +fun showKeymapDialog( + project: Project, + actionId: String, +) { + try { + EditKeymapsDialog(project, actionId) + .show() + } catch (e: Throwable) { + // this might fail on the first request so we do this + try { + EditKeymapsDialog(project, actionId) + .show() + } catch (e: Throwable) { + ShowSettingsUtilImpl.showSettingsDialog(project, "preferences.keymap", null) + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/CompressionUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/CompressionUtils.kt new file mode 100644 index 0000000..3ebd7dc --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/CompressionUtils.kt @@ -0,0 +1,101 @@ +package com.oxidecode.utils + +import com.aayushatharva.brotli4j.Brotli4jLoader +import com.aayushatharva.brotli4j.encoder.Encoder +import com.intellij.openapi.diagnostic.Logger +import java.io.IOException + +/** + * Utility class for handling request compression + */ +object CompressionUtils { + private val logger = Logger.getInstance(CompressionUtils::class.java) + + enum class CompressionType( + val encoding: String, + ) { + BROTLI("br"), + NONE("identity"), + } + + private var brotliAvailable: Boolean = false + + init { + // Ensure Brotli4j native library is loaded + try { + if (!Brotli4jLoader.isAvailable()) { + Brotli4jLoader.ensureAvailability() + } + brotliAvailable = Brotli4jLoader.isAvailable() + } catch (e: Exception) { + logger.warn("Brotli native library not available: ${e.message}") + brotliAvailable = false + } + } + + /** + * Compresses data using the specified compression type + * @param data The data to compress + * @param type The compression type to use + * @return The compressed data + * @throws IOException if compression fails + */ + @Throws(IOException::class) + fun compress( + data: ByteArray, + type: CompressionType, + ): ByteArray { + val startTime = System.nanoTime() + val originalSize = data.size + + val result = + when (type) { + CompressionType.BROTLI -> { + if (!brotliAvailable) { + logger.warn("Brotli not available, returning uncompressed data") + return data + } + try { + Encoder.compress(data, Encoder.Parameters().setQuality(1)) + } catch (e: Exception) { + logger.warn("Brotli compression failed, returning uncompressed data: ${e.message}") + data + } + } + CompressionType.NONE -> data + } + + val endTime = System.nanoTime() + val durationMicros = (endTime - startTime) / 1000.0 + val compressedSize = result.size + val compressionRate = calculateCompressionRatio(originalSize, compressedSize) + + logger.debug( + "Compression completed - Type: ${type.encoding}, Duration: ${"%.2f".format(durationMicros)}μs, " + + "Original size: $originalSize bytes, Compressed size: $compressedSize bytes, " + + "Compression rate: ${"%.2f".format(compressionRate)}%", + ) + + return result + } + + /** + * Calculates the compression ratio as a percentage + * @param originalSize The original data size + * @param compressedSize The compressed data size + * @return The compression ratio as a percentage (0-100) + */ + fun calculateCompressionRatio( + originalSize: Int, + compressedSize: Int, + ): Double { + if (originalSize == 0) return 0.0 + return ((originalSize - compressedSize).toDouble() / originalSize.toDouble()) * 100.0 + } + + /** + * Checks if Brotli compression is available + * @return true if Brotli compression is available, false otherwise + */ + fun isBrotliAvailable(): Boolean = brotliAvailable +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DatabaseOperationQueue.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DatabaseOperationQueue.kt new file mode 100644 index 0000000..d3c7ac3 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DatabaseOperationQueue.kt @@ -0,0 +1,128 @@ +package com.oxidecode.utils + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import java.util.concurrent.CompletableFuture +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +@Service(Service.Level.PROJECT) +class DatabaseOperationQueue( + private val project: Project, +) { + private val logger = Logger.getInstance(DatabaseOperationQueue::class.java) + private val entityDbQueue = LinkedBlockingQueue<() -> Unit>() + private val fileDbQueue = LinkedBlockingQueue<() -> Unit>() + + enum class QueueType { FILE, ENTITY } + + companion object { + fun getInstance(project: Project): DatabaseOperationQueue = project.getService(DatabaseOperationQueue::class.java) + } + + init { + // Start workers to process each queue + startQueueWorker(entityDbQueue, "EntityDB-Worker") + startQueueWorker(fileDbQueue, "FileDB-Worker") + } + + private fun startQueueWorker( + queue: LinkedBlockingQueue<() -> Unit>, + name: String, + ) { + ApplicationManager.getApplication().executeOnPooledThread { + Thread.currentThread().name = name + while (!project.isDisposed) { + try { + val operation = queue.take() // Blocks until an operation is available + operation() + } catch (e: Exception) { + println("Exception occurred while processing $e") + } + } + } + } + + fun enqueueEntityOperation(operation: () -> Unit) { + entityDbQueue.offer(operation) + } + + fun enqueueFileOperation(operation: () -> Unit) { + fileDbQueue.offer(operation) + } + + fun executeDbOperationWithTimeout( + queueOperation: (CompletableFuture, AtomicBoolean) -> Unit, + timeoutMs: Long = 500, + errorMsg: String = "Error executing database operation", + timeoutMsg: String = "Timeout executing database operation", + defaultValue: T, + ): T { + val resultFuture = CompletableFuture() + val canceled = AtomicBoolean(false) + + queueOperation(resultFuture, canceled) + + try { + return resultFuture.get(timeoutMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + canceled.set(true) + logger.info(timeoutMsg) + return defaultValue + } catch (e: Exception) { + logger.warn("$errorMsg: ${e.message}", e) + return defaultValue + } + } + + private fun isQueueBusy(queueType: QueueType): Boolean = + when (queueType) { + QueueType.FILE -> fileDbQueue.isNotEmpty() + QueueType.ENTITY -> entityDbQueue.isNotEmpty() + } + + fun executeDbOperationSkipIfBusy( + queueType: QueueType, + queueOperation: (CompletableFuture, AtomicBoolean) -> Unit, + timeoutMs: Long = 500, + errorMsg: String = "Error executing database operation", + timeoutMsg: String = "Timeout executing database operation", + defaultValue: T, + ): T { + // Skip operation if queue is already busy + if (isQueueBusy(queueType)) { + logger.debug("${queueType.name} queue busy, skipping operation") + return defaultValue + } + + // Proceed with normal operation if queue appears empty + return executeDbOperationWithTimeout( + queueOperation, + timeoutMs, + errorMsg, + timeoutMsg, + defaultValue, + ) + } + + fun clearQueue(queueType: QueueType) { + val queue = getQueue(queueType) + val sizeBefore = queue.size + if (sizeBefore > 0) { + logger.info("Clearing ${queueType.name} queue. Removing $sizeBefore pending operations.") + queue.clear() + } else { + logger.debug("${queueType.name} queue is already empty.") + } + } + + private fun getQueue(queueType: QueueType): LinkedBlockingQueue<() -> Unit> = + when (queueType) { + QueueType.FILE -> fileDbQueue + QueueType.ENTITY -> entityDbQueue + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DiffManager.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DiffManager.kt new file mode 100644 index 0000000..352715a --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DiffManager.kt @@ -0,0 +1,133 @@ +package com.oxidecode.utils + +import com.intellij.diff.comparison.ComparisonManager +import com.intellij.diff.comparison.ComparisonPolicy +import com.intellij.diff.fragments.LineFragment +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.progress.EmptyProgressIndicator + +/** + * Handles diff computation and highlighting for the editor + */ +class DiffManager( + val editor: Editor, +) { + companion object { + /** + * Compare original lines vs modified lines to produce line-level fragments. + */ + fun getDiffLineFragments( + originalLines: List, + modifiedLines: List, + lineSeparator: String, + ): List { + val comparisonManager = ComparisonManager.getInstance() + val fragments = + comparisonManager.compareLines( + originalLines.joinToString(lineSeparator), + modifiedLines.joinToString(lineSeparator), + ComparisonPolicy.DEFAULT, + EmptyProgressIndicator(), + ) + return coalesceLineFragments(fragments) + } + + private const val MERGE_THRESHOLD = 0 + + private fun coalesceLineFragments(lineFragments: List): List { + if (lineFragments.isEmpty()) return emptyList() + + val result = mutableListOf() + var current = lineFragments[0] + + for (i in 1 until lineFragments.size) { + val next = lineFragments[i] + + // Merge only when fragments are adjacent on BOTH sides (original and modified) + val gap1 = next.startLine1 - current.endLine1 + val gap2 = next.startLine2 - current.endLine2 + if (gap1 <= MERGE_THRESHOLD && gap2 <= MERGE_THRESHOLD) { + current = + com.intellij.diff.fragments.LineFragmentImpl( + current.startLine1, + next.endLine1, + current.startLine2, + next.endLine2, + current.startOffset1, + next.endOffset1, + current.startOffset2, + next.endOffset2, + ) + } else { + result.add(current) + current = next + } + } + + result.add(current) + return result + } + + fun getDiffLineFragments( + originalLines: String, + modifiedLines: String, + ): List { + val comparisonManager = ComparisonManager.getInstance() + val fragments = + comparisonManager.compareLines( + originalLines, + modifiedLines, + ComparisonPolicy.DEFAULT, + EmptyProgressIndicator(), + ) + // Coalesce adjacent line fragments + return coalesceLineFragments(fragments) + } + } + + /** + * Convert line fragments into a single combined "diff" string. + */ + fun getDiffString( + originalLines: List, + modifiedLines: List, + lineFragments: List, + ): String { + val diffLines = mutableListOf() + var currentLine = 0 + + lineFragments.forEach { fragment -> + // Add unchanged lines + while (currentLine < fragment.startLine1) { + diffLines.add(originalLines[currentLine]) + currentLine++ + } + + // Add removed lines + for (i in fragment.startLine1 until fragment.endLine1) { + diffLines.add(originalLines[i]) + currentLine = i + 1 + } + + // Add new lines + for (i in fragment.startLine2 until fragment.endLine2) { + diffLines.add(modifiedLines[i]) + } + } + + // Add remaining unchanged lines + while (currentLine < originalLines.size) { + diffLines.add(originalLines[currentLine]) + currentLine++ + } + + return diffLines.joinToString("\n") + } + + /** + * Remove all highlights from the editor. + */ + fun clearHighlights() { + editor.markupModel.removeAllHighlighters() + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DiffUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DiffUtils.kt new file mode 100644 index 0000000..96efb91 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/DiffUtils.kt @@ -0,0 +1,589 @@ +package com.oxidecode.utils + +import com.github.difflib.DiffUtils +import com.github.difflib.UnifiedDiffUtils +import com.github.difflib.patch.Patch +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.changes.Change +import com.intellij.openapi.vfs.VirtualFile +import org.eclipse.jgit.diff.DiffAlgorithm +import org.eclipse.jgit.diff.DiffFormatter +import org.eclipse.jgit.diff.RawText +import org.eclipse.jgit.diff.RawTextComparator +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.charset.StandardCharsets + +fun getDiff( + oldContent: String, + newContent: String, + oldFileName: String = "oldFile", + newFileName: String = "newFile", + context: Int = 3, + cleanEndings: Boolean = false, +): String { + // Normalize line endings to LF for both inputs to ensure consistent comparison + val normalizedOldContent = oldContent.replace("\r\n", "\n") + val normalizedNewContent = newContent.replace("\r\n", "\n") + + val diffOutput = + ByteArrayOutputStream() + .apply { + val (finalOldFile, finalNewFile) = + if (oldFileName == newFileName) { + "a/$oldFileName" to "b/$newFileName" + } else { + oldFileName to newFileName + } + + val (oldText, newText) = + if (cleanEndings) { + RawText((normalizedOldContent.trimEnd() + "\n").toByteArray(StandardCharsets.UTF_8)) to + RawText((normalizedNewContent.trimEnd() + "\n").toByteArray(StandardCharsets.UTF_8)) + } else { + RawText(normalizedOldContent.toByteArray(StandardCharsets.UTF_8)) to + RawText(normalizedNewContent.toByteArray(StandardCharsets.UTF_8)) + } + + val comparator = RawTextComparator.DEFAULT + + val edits = + DiffAlgorithm + .getAlgorithm(DiffAlgorithm.SupportedAlgorithm.MYERS) + .diff(comparator, oldText, newText) + + DiffFormatter(this).apply { + setContext(context) + setDiffComparator(comparator) + write("--- $finalOldFile\n".toByteArray()) + write("+++ $finalNewFile\n".toByteArray()) + format(edits, oldText, newText) + flush() + } + }.toString(StandardCharsets.UTF_8) + .replace("\\ No newline at end of file\n", "") + return diffOutput +} + +data class DiffGroup( + val deletions: String, + val additions: String, + val index: Int, +) { + val hasAdditions + get() = additions.isNotEmpty() + + val hasDeletions + get() = deletions.isNotEmpty() +} + +val List.isAllAdditions + get() = none { it.hasDeletions } + +val List.isAllDeletions + get() = none { it.hasAdditions } + +// Newline insertion in the middle of a line +fun List.isComplexChange(contents: String) = + any { + it.additions.contains('\n') && + (it.index < contents.length && contents[it.index] != '\n') && + (it.index == 0 || contents[it.index - 1] != '\n') + } + +fun computeCharacterDiff( + oldContent: String, + newContent: String, +): List { + // Optimization: Check for simple prefix/suffix insertions first + // This handles cases like "add(value)" -> "add(value, max_depth=None)" + // where the naive character diff might produce suboptimal alignments like "e, max_depth=Non" + + // Case 1: newContent contains oldContent as a prefix (insertion at end) + if (newContent.startsWith(oldContent)) { + val addition = newContent.removePrefix(oldContent) + if (addition.isNotEmpty()) { + return listOf( + DiffGroup( + deletions = "", + additions = addition, + index = oldContent.length, + ), + ) + } + } + + // Case 2: newContent contains oldContent as a suffix (insertion at start) + if (newContent.endsWith(oldContent)) { + val addition = newContent.removeSuffix(oldContent) + if (addition.isNotEmpty()) { + return listOf( + DiffGroup( + deletions = "", + additions = addition, + index = 0, + ), + ) + } + } + + // Case 3: Check if newContent is oldContent with an insertion in the middle + // Find the longest common prefix and suffix + var commonPrefixLen = 0 + while (commonPrefixLen < oldContent.length && + commonPrefixLen < newContent.length && + oldContent[commonPrefixLen] == newContent[commonPrefixLen] + ) { + commonPrefixLen++ + } + + var commonSuffixLen = 0 + while (commonSuffixLen < oldContent.length - commonPrefixLen && + commonSuffixLen < newContent.length - commonPrefixLen && + oldContent[oldContent.length - 1 - commonSuffixLen] == newContent[newContent.length - 1 - commonSuffixLen] + ) { + commonSuffixLen++ + } + + // If the common prefix + suffix covers all of oldContent, it's a pure insertion + if (commonPrefixLen + commonSuffixLen >= oldContent.length) { + val insertionStart = commonPrefixLen + val insertionEnd = newContent.length - commonSuffixLen + if (insertionEnd > insertionStart) { + return listOf( + DiffGroup( + deletions = "", + additions = newContent.substring(insertionStart, insertionEnd), + index = commonPrefixLen, + ), + ) + } + } + + // Fall back to standard character diff for more complex changes + val patch = DiffUtils.diff(oldContent.toMutableList(), newContent.toMutableList()) + + return patch.deltas.map { delta -> + DiffGroup( + deletions = delta.source.lines.joinToString(""), + additions = delta.target.lines.joinToString(""), + index = delta.source.position, + ) + } +} + +fun computeWordDiff( + oldContent: String, + newContent: String, +): List { + // Split on word boundaries and keep delimiters as tokens + val oldWords = oldContent.split(Regex("(?<=[^\\w\n])|(?=[^\\w\n])|(?<=\n)|(?=\n)")).filter { it.isNotEmpty() } + val newWords = newContent.split(Regex("(?<=[^\\w\n])|(?=[^\\w\n])|(?<=\n)|(?=\n)")).filter { it.isNotEmpty() } + + val patch = DiffUtils.diff(oldWords, newWords) + + return patch.deltas.sortedBy { it.source.position }.map { delta -> + // Calculate the actual character position by summing lengths of preceding words + val position = oldWords.take(delta.source.position).joinToString("").length + + DiffGroup( + deletions = delta.source.lines.joinToString(""), + additions = delta.target.lines.joinToString(""), + index = position, + ) + } +} + +fun computeDiffGroups( + oldContent: String, + newContent: String, +): List { + // Here's how it works: + // 1. If it's only adding lines, we take the added lines as diffs. + // 2. If it's only adding characters, we take the added characters as diffs. + // 3. Otherwise, if some lines have both additions and deletions, then we take the word diffs for those lines. + + if (newContent.isEmpty() && oldContent.isNotEmpty()) { + return listOf(DiffGroup(deletions = oldContent, additions = "", index = 0)) + } + + val oldLines = oldContent.lines() + val newLines = newContent.lines() + val linePatch = DiffUtils.diff(oldLines, newLines) + + val diffGroups = mutableListOf() + + fun joinLines(lines: List): String = lines.joinToString("\n") + (if (lines.size == 1 && lines.first().isEmpty()) "\n" else "") + + val deltas = linePatch.deltas + + deltas.forEach { delta -> + val oldText = joinLines(delta.source.lines) + val newText = joinLines(delta.target.lines) + + // Calculate starting position in original text + val position = + oldLines.take(delta.source.position).joinToString("\n").length + + if (delta.source.position > 0) 1 else 0 // Add 1 for newline if not at start + + if (oldText.isEmpty()) { + // Pure addition + diffGroups.add( + DiffGroup( + deletions = "", + additions = newText + "\n", + index = position, + ), + ) + } else if (newText.isEmpty()) { + // Pure deletion + if (delta.source.lines == listOf("")) { + // weird edge case, watch out + diffGroups.add( + DiffGroup( + deletions = "\n", + additions = "", + index = position, + ), + ) + } else { + diffGroups.add( + DiffGroup( + deletions = oldText + "\n", + additions = "", + index = position, + ), + ) + } + } else { + // if it is one line and can be represented as all character additions just show char diff (ghost text) + val innerDiffs = + if (deltas.size == 1 && + delta.source.lines.size <= 1 && + delta.target.lines.size <= 1 + ) { + val charDiffs = computeCharacterDiff(oldText, newText) + if (charDiffs.isAllAdditions && charDiffs.size == 1) { + charDiffs + } else { + computeWordDiff(oldText, newText) + } + } else { + computeWordDiff(oldText, newText) + } + innerDiffs.forEach { hunk -> + if (hunk.hasAdditions && hunk.hasDeletions) { + if (hunk.additions.startsWith(hunk.deletions)) { + diffGroups.add( + DiffGroup( + deletions = "", + additions = hunk.additions.removePrefix(hunk.deletions), + index = position + hunk.index + hunk.deletions.length, + ), + ) + } else if (hunk.additions.endsWith(hunk.deletions)) { + diffGroups.add( + DiffGroup( + deletions = "", + additions = hunk.additions.removeSuffix(hunk.deletions), + index = position + hunk.index, + ), + ) + } else { + // TODO: add deletion cases as well + diffGroups.add( + DiffGroup( + deletions = hunk.deletions, + additions = hunk.additions, + index = position + hunk.index, + ), + ) + } + } else { + diffGroups.add( + DiffGroup( + deletions = hunk.deletions, + additions = hunk.additions, + index = position + hunk.index, + ), + ) + } + } + } + } + + return diffGroups.sortedBy { it.index } +} + +data class DiffInfo( + val changeTypeMessage: String, + val fileName: String, + val unifiedDiff: List, +) + +fun truncateDiff( + diffInfo: DiffInfo, + maxChars: Int, +): DiffInfo { + val header = "${diffInfo.changeTypeMessage}: ${diffInfo.fileName}\n" + val remainingChars = maxChars - header.length + + var currentLength = 0 + val truncatedLines = mutableListOf() + + for (line in diffInfo.unifiedDiff) { + if (currentLength + line.length + 1 > remainingChars) { + truncatedLines.add("... (diff truncated)") + break + } + truncatedLines.add(line) + currentLength += line.length + 1 // +1 for newline + } + + return DiffInfo( + changeTypeMessage = diffInfo.changeTypeMessage, + fileName = diffInfo.fileName, + unifiedDiff = truncatedLines, + ) +} + +fun relativePath( + project: Project, + vf: VirtualFile?, +): String? = + runCatching { + vf?.path?.takeIf { project.osBasePath != null }?.let { + File(it).relativeTo(File(project.osBasePath!!)).toString() + } + }.getOrNull()?.takeUnless { it.isBlank() || it.startsWith("..") } + +fun generateDiffStringFromChanges( + changes: List, + project: Project? = null, +): String { + // Check if project is disposed at the beginning + if (project?.isDisposed == true) { + return "" + } + + val diffBuilder = StringBuilder() + val diffs = mutableListOf() + + changes.forEach { change -> + // Check disposal status before accessing revision content + if (project?.isDisposed == true) { + return@forEach + } + val beforeFile = change.beforeRevision?.file?.virtualFile + val afterFile = change.afterRevision?.file?.virtualFile + + // Get relative path if project is provided, otherwise use file name + val beforeFileName = + when { + project != null && beforeFile != null -> relativePath(project, beforeFile) ?: beforeFile.name + else -> change.beforeRevision?.file?.name ?: "unknown" + } + + val afterFileName = + when { + project != null && afterFile != null -> relativePath(project, afterFile) ?: afterFile.name + else -> change.afterRevision?.file?.name ?: "unknown" + } + + // Add size check before processing content + val beforeSize = + change.beforeRevision + ?.file + ?.virtualFile + ?.length ?: 0L + val afterSize = + change.afterRevision + ?.file + ?.virtualFile + ?.length ?: 0L + if (beforeSize > 20 * 1024 * 1024 || afterSize > 20 * 1024 * 1024) { + diffBuilder.append("Skipped large file: $afterFileName (size exceeds 20MB)\n\n") + return@forEach + } + + val type = change.type + val oldContent = change.beforeRevision?.content ?: "" + val newContent = change.afterRevision?.content ?: "" + + val oldLines = oldContent.lines() + val newLines = newContent.lines() + + val patch: Patch = DiffUtils.diff(oldLines, newLines) + + val unifiedDiff: List = + UnifiedDiffUtils.generateUnifiedDiff( + beforeFileName, + afterFileName, + oldLines, + patch, + 2, + ) + val changeTypeMessage = + when (type) { + Change.Type.NEW -> "Added new file" + Change.Type.DELETED -> "Deleted file" + Change.Type.MOVED -> "Moved/renamed file" + else -> "Modified file" + } + + diffs.add(DiffInfo(changeTypeMessage, afterFileName, unifiedDiff)) + } + + // Calculate character count for each diff and sort by size + val diffsWithSize = + diffs + .map { diffInfo -> + val headerLength = "${diffInfo.changeTypeMessage}: ${diffInfo.fileName}\n".length + val diffLength = diffInfo.unifiedDiff.sumOf { it.length + 1 } + val totalLength = headerLength + diffLength + 2 + Pair(diffInfo, totalLength) + }.sortedByDescending { it.second } + + // Keep only the largest diffs that fit within 500000 characters + val maxChars = 500000 + // max diff size is 250k + val maxSingleDiffChars = 250000 + var currentTotal = 0 + val trimmedDiffs = mutableListOf() + + diffsWithSize.forEach { (diffInfo, size) -> + when { + // If this is the first diff and it's too large, truncate it + trimmedDiffs.isEmpty() && size > maxSingleDiffChars -> { + val truncatedDiff = truncateDiff(diffInfo, maxSingleDiffChars) + trimmedDiffs.add(truncatedDiff) + currentTotal += maxSingleDiffChars + } + // If adding this diff won't exceed the max chars, add it + currentTotal + size <= maxChars -> { + trimmedDiffs.add(diffInfo) + currentTotal += size + } + // Otherwise, skip this diff + else -> return@forEach + } + } + + // Now build the diff string + trimmedDiffs.forEach { diffInfo -> + diffBuilder.append("${diffInfo.changeTypeMessage}: ${diffInfo.fileName}\n") + diffBuilder.append(diffInfo.unifiedDiff.joinToString(separator = "\n")) + diffBuilder.append("\n\n") + } + + return diffBuilder.toString() +} + +fun generateDiffStringFromUnversionedFiles( + unversionedFiles: List, + project: Project? = null, +): String { + // Check if project is disposed at the beginning + if (project?.isDisposed == true) { + return "" + } + + val diffBuilder = StringBuilder() + val diffs = mutableListOf() + + unversionedFiles.forEach { filePath -> + // Check disposal status before processing + if (project?.isDisposed == true) { + return@forEach + } + + val virtualFile = filePath.virtualFile ?: return@forEach + + // Skip directories + if (virtualFile.isDirectory) { + return@forEach + } + + // Get relative path if project is provided, otherwise use file name + val fileName = + if (project != null) { + relativePath(project, virtualFile) ?: virtualFile.name + } else { + filePath.name + } + + // Add size check before processing content + val fileSize = virtualFile.length + if (fileSize > 20 * 1024 * 1024) { + diffBuilder.append("Skipped large file: $fileName (size exceeds 20MB)\n\n") + return@forEach + } + + // Read file content + val content = + try { + String(virtualFile.contentsToByteArray(), virtualFile.charset) + } catch (e: Exception) { + return@forEach + } + + val newLines = content.lines() + + // Generate unified diff for new file (empty old content) + val patch: Patch = DiffUtils.diff(emptyList(), newLines) + val unifiedDiff: List = + UnifiedDiffUtils.generateUnifiedDiff( + "/dev/null", + fileName, + emptyList(), + patch, + 2, + ) + + diffs.add(DiffInfo("Added new file (unversioned)", fileName, unifiedDiff)) + } + + // Calculate character count for each diff and sort by size + val diffsWithSize = + diffs + .map { diffInfo -> + val headerLength = "${diffInfo.changeTypeMessage}: ${diffInfo.fileName}\n".length + val diffLength = diffInfo.unifiedDiff.sumOf { it.length + 1 } + val totalLength = headerLength + diffLength + 2 + Pair(diffInfo, totalLength) + }.sortedByDescending { it.second } + + // Keep only the largest diffs that fit within 500000 characters + val maxChars = 500000 + val maxSingleDiffChars = 250000 + var currentTotal = 0 + val trimmedDiffs = mutableListOf() + + diffsWithSize.forEach { (diffInfo, size) -> + when { + // If this is the first diff and it's too large, truncate it + trimmedDiffs.isEmpty() && size > maxSingleDiffChars -> { + val truncatedDiff = truncateDiff(diffInfo, maxSingleDiffChars) + trimmedDiffs.add(truncatedDiff) + currentTotal += maxSingleDiffChars + } + // If adding this diff won't exceed the max chars, add it + currentTotal + size <= maxChars -> { + trimmedDiffs.add(diffInfo) + currentTotal += size + } + // Otherwise, skip this diff + else -> return@forEach + } + } + + // Now build the diff string + trimmedDiffs.forEach { diffInfo -> + diffBuilder.append("${diffInfo.changeTypeMessage}: ${diffInfo.fileName}\n") + diffBuilder.append(diffInfo.unifiedDiff.joinToString(separator = "\n")) + diffBuilder.append("\n\n") + } + + return diffBuilder.toString() +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/EditorUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/EditorUtils.kt new file mode 100644 index 0000000..54348a9 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/EditorUtils.kt @@ -0,0 +1,644 @@ +package com.oxidecode.utils + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.ToolWindowManager +import com.oxidecode.data.SelectedSnippet +import com.oxidecode.services.OxideCodeNonProjectFilesService +import java.io.File +import java.nio.file.InvalidPathException +import java.nio.file.Paths +import kotlin.math.min + +/** + * Efficiently extracts text from a Document with line and character limits. + * Uses Document's native range-based APIs to avoid loading unnecessary content into memory. + * @param maxLines Maximum lines to extract, or -1 for no limit + * @param maxChars Maximum characters to extract, or -1 for no limit + */ +private fun extractTextFromDocument( + document: Document, + maxLines: Int, + maxChars: Int, +): String { + // If no limits, return full document text + if (maxLines == -1 && maxChars == -1) { + return document.text + } + + val totalLines = document.lineCount + val linesToExtract = if (maxLines == -1) totalLines else min(totalLines, maxLines) + + // If no lines to extract, return empty + if (linesToExtract == 0) return "" + + // Calculate the range we need using Document's built-in methods + val startOffset = 0 + val endLineOffset = document.getLineEndOffset(linesToExtract - 1) + + // Extract only the text range we need (memory efficient!) + val textRange = TextRange(startOffset, min(endLineOffset, document.textLength)) + var extractedText = document.charsSequence.subSequence(textRange.startOffset, textRange.endOffset).toString() + + // Apply character limit if specified + val truncatedByChars = maxChars != -1 && extractedText.length > maxChars + if (truncatedByChars) { + extractedText = extractedText.substring(0, maxChars) + } + + // Add truncation message if needed + val truncatedByLines = maxLines != -1 && totalLines > maxLines + return if (truncatedByLines || truncatedByChars) { + buildString { + append(extractedText) + append("\n\n[File contents truncated: ") + if (truncatedByLines) { + append("showing first $linesToExtract of $totalLines lines") + } + if (truncatedByChars) { + if (truncatedByLines) append(", ") + append("limited to $maxChars characters") + } + append("]") + } + } else { + extractedText + } +} + +/** + * Simple text truncation for plain strings (not Documents). + * Used when reading from files directly. + * @param maxLines Maximum lines to return, or -1 for no limit + * @param maxChars Maximum characters to return, or -1 for no limit + */ +private fun truncateText( + text: String, + maxLines: Int, + maxChars: Int, +): String { + // If no limits, return full text + if (maxLines == -1 && maxChars == -1) { + return text + } + + if (text.isEmpty()) return text + + val lines = text.lines() + val totalLines = lines.size + val linesToTake = if (maxLines == -1) totalLines else min(totalLines, maxLines) + + // Take up to maxLines + val linesTruncated = lines.take(linesToTake) + var joinedText = linesTruncated.joinToString("\n") + + // Apply character limit if specified + val truncatedByChars = maxChars != -1 && joinedText.length > maxChars + if (truncatedByChars) { + joinedText = joinedText.substring(0, maxChars) + } + + // Add truncation message if needed + val truncatedByLines = maxLines != -1 && totalLines > maxLines + return if (truncatedByLines || truncatedByChars) { + buildString { + append(joinedText) + append("\n\n[File contents truncated: ") + if (truncatedByLines) { + append("showing first $linesToTake of $totalLines lines") + } + if (truncatedByChars) { + if (truncatedByLines) append(", ") + append("limited to $maxChars characters") + } + append("]") + } + } else { + joinedText + } +} + +/** + * Memory-efficiently reads a file with size limits. + * Avoids loading huge files into memory by reading line-by-line when necessary. + * @param maxLines Maximum lines to read, or -1 for no limit + * @param maxChars Maximum characters to read, or -1 for no limit + */ +private fun readFileWithLimits( + file: File, + maxLines: Int, + maxChars: Int, +): String? { + if (!file.exists() || !file.canRead()) return null + + // If no limits, read entire file + if (maxLines == -1 && maxChars == -1) { + return file.readText() + } + + val fileSize = file.length() + val estimatedSafeSize = if (maxLines == -1) Long.MAX_VALUE else maxLines * 100L // Rough estimate: 100 chars per line + + // If file is small enough, read it all at once and truncate + return if (fileSize <= estimatedSafeSize) { + val content = file.readText() + truncateText(content, maxLines, maxChars) + } else { + // File is large, read line by line to avoid memory issues + val lines = mutableListOf() + var totalChars = 0 + var reachedLimit = false + + file.bufferedReader().use { reader -> + var lineCount = 0 + while (maxLines == -1 || lineCount < maxLines) { + val line = reader.readLine() ?: break + + // Check if adding this line would exceed char limit + if (maxChars != -1 && totalChars + line.length + 1 > maxChars) { + // Take partial line to reach exactly maxChars + val remainingChars = maxChars - totalChars - 1 + if (remainingChars > 0) { + lines.add(line.substring(0, min(line.length, remainingChars))) + } + reachedLimit = true + break + } + + lines.add(line) + totalChars += line.length + 1 // +1 for newline + lineCount++ + } + } + + val result = lines.joinToString("\n") + if (reachedLimit || (maxLines != -1 && lines.size >= maxLines)) { + result + "\n\n[File contents truncated: showing first ${lines.size} lines, limited to $maxChars characters]" + } else { + result + } + } +} + +fun readFile( + project: Project, + filePath: String, + maxLines: Int = -1, + maxChars: Int = -1, +): String? { + val application = ApplicationManager.getApplication() + val maxFileSize = OxideCodeConstants.MAX_FILE_SIZE_BYTES + val filePath = FileUtil.toSystemIndependentName(filePath) + + fun readFromEditor(): String? { + // Add project disposal guard to prevent ContainerDisposedException + if (project.isDisposed) { + return null + } + + return FileEditorManager + .getInstance(project) + .allEditors + .mapNotNull { it.file } + .find { it.path.endsWith(filePath) } + ?.let { file -> + if (file.length > maxFileSize) { + null + } else { + FileDocumentManager.getInstance().getDocument(file)?.let { document -> + extractTextFromDocument(document, maxLines, maxChars) + } + } + } + } + + val textFromEditor = + if (application.isReadAccessAllowed) { + readFromEditor() + } else { + application.runReadAction { readFromEditor() } + } + + return textFromEditor + ?: runCatching { + val file = File(project.osBasePath, filePath).takeIf { it.exists() && it.canRead() } + if (file != null && file.length() > maxFileSize) { + null + } else { + file?.let { readFileWithLimits(it, maxLines, maxChars) } + } + }.getOrNull() +} + +fun readFile( + project: Project, + vFile: VirtualFile?, + maxLines: Int = -1, + maxChars: Int = -1, +): String? { + val filePath = relativePath(project, vFile) ?: return null + return readFile(project, filePath, maxLines, maxChars) +} + +fun getVirtualFile( + project: Project, + path: String, + refresh: Boolean = false, +): VirtualFile? { + val absolutePath = absolutePath(project, path) + return if (refresh) { + LocalFileSystem.getInstance().refreshAndFindFileByPath(absolutePath) + } else { + LocalFileSystem.getInstance().findFileByPath(absolutePath) + } +} + +fun relativePath( + basePath: String, + fullPath: String, +): String? { + if (BLOCKED_URL_PREFIXES.any { fullPath.startsWith(it) }) { + return null + } + return try { + val basePathNorm = File(basePath).toPath().normalize().toString() + val fullPathNorm = File(fullPath).toPath().normalize().toString() + if (fullPathNorm.startsWith(basePathNorm)) { + fullPathNorm.substring(basePathNorm.length).trimStart(File.separatorChar) + } else { + null + } + } catch (e: InvalidPathException) { + null + } +} + +fun relativePath( + project: Project, + fullPath: String, +): String? { + // Add disposal check before accessing project service + if (project.isDisposed) { + return project.osBasePath?.let { basePath -> relativePath(basePath, fullPath) } + } + + // Check if it's a non-project file managed by OxideCodeNonProjectFilesService + if (OxideCodeNonProjectFilesService.getInstance(project).isAllowedFile(fullPath)) return fullPath + return project.osBasePath?.let { basePath -> relativePath(basePath, fullPath) } +} + +fun absolutePath( + project: Project, + relativePath: String, +): String { + if (File(relativePath).isAbsolute) return relativePath + + if (project.isDisposed) { + return File(relativePath).absolutePath // Fallback to system absolute path + } + + if (OxideCodeNonProjectFilesService.getInstance(project).isAllowedFile(relativePath)) return relativePath + return File(project.osBasePath!!, relativePath).path +} + +fun getCurrentSelectedFile(project: Project): VirtualFile? { + // Add disposal check before accessing project service + if (project.isDisposed) { + return null + } + + return FileEditorManager + .getInstance(project) + .selectedFiles + .filterNot { + OxideCodeConstants.diffFiles.contains(it.name) + }.firstOrNull { + OxideCodeNonProjectFilesService.getInstance(project).isAllowedFile(it.url) || + OxideCodeNonProjectFilesService.getInstance(project).isAllowedFile(it.path) || + ( + it.isInLocalFileSystem && + try { + VfsUtil.isAncestor(File(project.osBasePath!!).toPath().toFile(), it.toNioPath().toFile(), false) + } catch (e: UnsupportedOperationException) { + false + } + ) + } +} + +fun getAllOpenFiles(project: Project): List { + // Add disposal check before accessing project service + if (project.isDisposed) { + return emptyList() + } + + return FileEditorManager + .getInstance(project) + .openFiles + .filter { + OxideCodeNonProjectFilesService.getInstance(project).isAllowedFile(it.url) || + ( + !OxideCodeConstants.diffFiles.contains(it.name) && + it.isInLocalFileSystem && + VfsUtil.isAncestor(File(project.osBasePath!!).toPath().toFile(), it.toNioPath().toFile(), false) + ) + } +} + +fun getAllOpenFilePaths( + project: Project, + relativePaths: Boolean = false, +): List = + getAllOpenFiles(project) + .mapNotNull { file -> + if (relativePaths) { + relativePath(project, file) + } else { + file.path + } + } + +fun getCurrentSelectedSnippet(project: Project): Pair? { + val application = ApplicationManager.getApplication() + + fun inner(): Pair? { + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return null + val document = editor.document + val file = FileDocumentManager.getInstance().getFile(document) ?: return null + val relativePath = relativePath(project, file) ?: return null + + return editor.selectionModel.takeIf { it.hasSelection() }?.run { + Pair( + SelectedSnippet( + file.name, + document.getLineNumber(selectionStart) + 1, + document.getLineNumber(selectionEnd) + 1, + ), + relativePath, + ) + } + } + + return if (application.isReadAccessAllowed) { + inner() + } else { + application.runReadAction?> { inner() } + } +} + +fun foldEditorOutside( + startLine: Int, + endLine: Int, + editor: Editor, + document: Document, + foldText: String = "", +) { + val startOffset = document.getLineStartOffset(startLine) + val endOffset = document.getLineEndOffset(endLine) + editor.scrollingModel.scrollToCaret(ScrollType.CENTER) + + editor.foldingModel.runBatchFoldingOperation { + if (startLine > 0) { + editor.foldingModel.addFoldRegion(0, startOffset, foldText)?.let { it.isExpanded = false } + } + if (endLine < document.lineCount - 1) { + editor.foldingModel.addFoldRegion(endOffset, document.textLength, foldText)?.let { it.isExpanded = false } + } + } +} + +fun foldEditorInside( + startLine: Int, + endLine: Int, + editor: Editor, + document: Document, + foldText: String = "", + showFirstWord: Boolean = true, +) { + val initialStartOffset = document.getLineStartOffset(startLine) + val endOffset = document.getLineEndOffset(endLine) + val startOffset = + if (showFirstWord) { + val lineText = document.charsSequence.subSequence(initialStartOffset, endOffset).toString() + val firstWordMatch = Regex("^(\\s*)(\\S+)\\s+").find(lineText) + if (firstWordMatch != null) { + initialStartOffset + firstWordMatch.groupValues[1].length + firstWordMatch.groupValues[2].length + 1 + } else { + initialStartOffset + } + } else { + initialStartOffset + } + editor.scrollingModel.scrollToCaret(ScrollType.CENTER) + + editor.foldingModel.runBatchFoldingOperation { + editor.foldingModel.addFoldRegion(startOffset, endOffset, foldText)?.let { it.isExpanded = false } + } +} + +fun configureReadOnlyEditor( + editor: Editor, + showLineNumbers: Boolean = true, +) { + editor.settings.apply { + additionalColumnsCount = 0 + additionalLinesCount = 0 + isAdditionalPageAtBottom = false + isVirtualSpace = false + isUseSoftWraps = true // things are weird if you set this to true + isLineMarkerAreaShown = false + setGutterIconsShown(false) + isLineNumbersShown = showLineNumbers + isCaretRowShown = false + isBlinkCaret = false + isCaretRowShown = false + } + + if (editor is EditorEx) { + editor.isViewer = true + } +} + +fun getSafeStartAndEndLines( + textRange: TextRange, + document: Document, +): Pair { + val startOffset = textRange.startOffset.coerceIn(0, document.textLength - 1) + val endOffset = textRange.endOffset.coerceIn(0, document.textLength - 1) + val startLine = document.getLineNumber(startOffset) + val endLine = document.getLineNumber(endOffset) + return Pair(startLine, endLine) +} + +fun openFileInEditor( + project: Project, + relativePath: String, + line: Int? = null, + useAbsolutePath: Boolean = false, +) { + val virtualFile = + if (useAbsolutePath) { + // Use relativePath as absolute path directly + LocalFileSystem.getInstance().findFileByPath(relativePath) + } else { + // Add disposal check before accessing project service + if (project.isDisposed) { + return + } + + // Check if it's a non-project file first + val nonProjectService = OxideCodeNonProjectFilesService.getInstance(project) + if (nonProjectService.isAllowedFile(relativePath)) { + // It's a non-project file, get it using the service + nonProjectService.getVirtualFileAssociatedWithAllowedFile(project, relativePath) + } else { + // It's a regular project file, use the existing approach + val basePath = project.basePath ?: return + + val absolutePath = + getAbsolutePathFromUri(relativePath) ?: run { + if (!File(relativePath).isAbsolute) { + Paths.get(basePath, relativePath).toString() + } else { + relativePath + } + } + LocalFileSystem.getInstance().findFileByPath(absolutePath) + } + } ?: return // Return if virtual file not found in either case + + ApplicationManager.getApplication().invokeLater { + // Add project disposal guard to prevent ContainerDisposedException + if (project.isDisposed) { + return@invokeLater + } + + if (line != null) { + val fileEditorManager = FileEditorManager.getInstance(project) + val editor = + fileEditorManager.openTextEditor( + OpenFileDescriptor(project, virtualFile, line - 1, 0), + true, + ) + // Scroll to the line + editor?.scrollingModel?.scrollTo( + LogicalPosition(line - 1, 0), + ScrollType.CENTER, + ) + } else { + FileEditorManager.getInstance(project).openFile(virtualFile, false) + } + } +} + +fun focusSweepTerminal(project: Project) { + ApplicationManager.getApplication().invokeLater { + // Add project disposal guard to prevent ContainerDisposedException + if (project.isDisposed) { + return@invokeLater + } + + val toolWindowManager = ToolWindowManager.getInstance(project) + val terminalToolWindow = toolWindowManager.getToolWindow("Terminal") + + terminalToolWindow?.let { toolWindow -> + // Show the terminal tool window if it's not visible + if (!toolWindow.isVisible) { + toolWindow.show() + } + + // Activate the tool window to bring it to focus + toolWindow.activate(null) + + // Find and select the "Sweep Terminal" tab + val contentManager = toolWindow.contentManager + val sweepTerminalContent = contentManager.findContent("Sweep Terminal") + + sweepTerminalContent?.let { content -> + contentManager.setSelectedContent(content) + } + } + } +} + +/** + * Strips surrounding quotes from a path string. + * Handles both single and double quotes. + * Examples: + * "C:\Program Files\Git\bin\bash.exe" -> C:\Program Files\Git\bin\bash.exe + * 'C:\Program Files\Git\bin\bash.exe' -> C:\Program Files\Git\bin\bash.exe + * C:\Program Files\Git\bin\bash.exe -> C:\Program Files\Git\bin\bash.exe (unchanged) + */ +private fun stripQuotes(path: String): String { + val trimmed = path.trim() + return when { + trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"') -> + trimmed.substring(1, trimmed.length - 1) + trimmed.length >= 2 && trimmed.startsWith('\'') && trimmed.endsWith('\'') -> + trimmed.substring(1, trimmed.length - 1) + else -> trimmed + } +} + +/** + * Extracts a simple shell name from a full shell path. + * Examples: + * /bin/bash -> bash + * /usr/bin/zsh -> zsh + * /opt/homebrew/bin/zsh -> zsh + * powershell.exe -> powershell + * C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -> powershell + * pwsh.exe -> powershell + * cmd.exe -> cmd + * "C:\Program Files\Git\bin\bash.exe" -> bash (handles quoted paths) + */ +fun extractShellName(shellPath: String): String { + if (shellPath.isBlank()) return "" + + // Strip surrounding quotes first (e.g., "C:\Program Files\Git\bin\bash.exe") + val unquotedPath = stripQuotes(shellPath) + + // Get the filename from the path + val fileName = + unquotedPath + .replace('\\', '/') + .substringAfterLast('/') + .lowercase() + + // Remove common extensions + val baseName = + fileName + .removeSuffix(".exe") + .removeSuffix(".cmd") + .removeSuffix(".bat") + + // Map common shell names to canonical names + return when { + baseName == "pwsh" -> "powershell" + baseName.contains("powershell") -> "powershell" + baseName == "cmd" -> "cmd" + baseName == "bash" -> "bash" + baseName == "zsh" -> "zsh" + baseName == "fish" -> "fish" + baseName == "sh" -> "sh" + baseName == "dash" -> "dash" + baseName == "ksh" -> "ksh" + baseName == "csh" -> "csh" + baseName == "tcsh" -> "tcsh" + baseName.startsWith("wsl") -> "wsl" + else -> baseName + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/Extensions.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/Extensions.kt new file mode 100644 index 0000000..423567b --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/Extensions.kt @@ -0,0 +1,512 @@ +package com.oxidecode.utils + +import com.intellij.openapi.editor.Document +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.ui.JBColor +import com.intellij.ui.scale.JBUIScale +import com.intellij.util.SlowOperations +import com.intellij.util.ui.JBUI +import com.oxidecode.theme.OxideCodeIcons.scale +import java.awt.* +import java.awt.event.* +import java.io.File +import java.text.Normalizer +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.SwingUtilities +import javax.swing.Timer +import javax.swing.border.Border +import javax.swing.border.EmptyBorder +import kotlin.math.abs +import kotlin.math.min + +fun Container.hasAnyFocusedDescendant(): Boolean { + val focusOwner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner ?: return false + + if (focusOwner == this) return true + + for (component in components) { + if (component is Container) { + if (component.hasAnyFocusedDescendant()) return true + } else if (component == focusOwner) { + return true + } + } + return false +} + +val Component.ancestors get() = generateSequence(this) { it.parent } + +var Component.identifier: Any? + get() = (this as? JComponent)?.getClientProperty("identifier") + set(value) = (this as? JComponent)?.putClientProperty("identifier", value) ?: Unit + +fun Component.walk(): Sequence = + sequence { + yield(this@walk) + + if (this@walk is Container) { + for (child in this@walk.components) { + yieldAll(child.walk()) + } + } + } + +fun Container.onDescendantAdded(callback: Component.() -> Unit) { + // runs callback whenever a descendent is added + // each descendent will add to their own descendent as well + // i checked with o1, this should be bug free + + lateinit var adaptor: ContainerAdapter + adaptor = + object : ContainerAdapter() { + override fun componentAdded(e: ContainerEvent) { + e.child.walk().forEach { + it.callback() + (it as? Container)?.addContainerListener(adaptor) + } + } + + override fun componentRemoved(e: ContainerEvent) { + e.child.walk().filterIsInstance().forEach { + it.removeContainerListener(adaptor) + } + } + } + walk() + .filterIsInstance() + .forEach { + it.addContainerListener(adaptor) + } +} + +fun Container.addMouseListenerRecursive(adapter: MouseAdapter) { + walk().forEach { it.addMouseListener(adapter) } + onDescendantAdded { addMouseListener(adapter) } +} + +fun Container.removeMouseListenerRecursive(adapter: MouseAdapter) { + walk().forEach { it.removeMouseListener(adapter) } +} + +fun Container.addFocusListenerRecursive(adaptor: FocusAdapter) { + walk().forEach { it.addFocusListener(adaptor) } + onDescendantAdded { addFocusListener(adaptor) } +} + +fun Container.removeFocusListenerRecursive(adaptor: FocusAdapter) { + walk().forEach { it.removeFocusListener(adaptor) } +} + +fun Container.addKeyListenerRecursive(adaptor: KeyAdapter) { + walk().forEach { it.addKeyListener(adaptor) } + onDescendantAdded { addKeyListener(adaptor) } +} + +fun Container.removeKeyListenerRecursive(adaptor: KeyAdapter) { + walk().forEach { it.removeKeyListener(adaptor) } +} + +inline fun Component.identifierEquals(value: T): Boolean = identifier is T && identifier == value + +fun Component.identifierStartsWith(prefix: String): Boolean = identifier is String && (identifier as String).startsWith(prefix) + +fun Component.identifierNotEquals(value: Any): Boolean = !identifierEquals(value) + +val Int.scaled: Int + get() = JBUI.scale(this) + +val Float.scaled: Float + get() = JBUIScale.scale(this) + +val Icon.scaled: Icon + get() = this.scale(12f.scaled) + +val Dimension.scaled: Dimension + get() = Dimension(width.scaled, height.scaled) + +val Border.scaled: Border + get() = + when (this) { + is EmptyBorder -> { + val insets = borderInsets + JBUI.Borders.empty( + insets.top.scaled, + insets.left.scaled, + insets.bottom.scaled, + insets.right.scaled, + ) + } + else -> this + } + +fun Color.customBrighter(factor: Float = 0.2f): Color { + val r = (this.red + 255 * factor).toInt().coerceIn(0, 255) + val g = (this.green + 255 * factor).toInt().coerceIn(0, 255) + val b = (this.blue + 255 * factor).toInt().coerceIn(0, 255) + + return Color(r, g, b, this.alpha) +} + +fun Color.customDarker(factor: Float = 0.2f): Color { + val r = (this.red - 255 * factor).toInt().coerceIn(0, 255) + val g = (this.green - 255 * factor).toInt().coerceIn(0, 255) + val b = (this.blue - 255 * factor).toInt().coerceIn(0, 255) + + return Color(r, g, b, this.alpha) +} + +fun Color.contrastWithTheme(): JBColor = + JBColor( + this.darker(), // Light mode gets darker version + this.brighter(), // Dark mode gets brighter version + ) + +fun Color.harmonizeWithTheme(): JBColor = + JBColor( + this.brighter(), // Light mode gets brighter version + this.darker(), // Dark mode gets darker version + ) + +fun Color.withLightMode(): JBColor { + val hsb = Color.RGBtoHSB(red, green, blue, null) + + // Adjust brightness while maintaining hue and reducing saturation + val lightVersion = + Color.getHSBColor( + hsb[0], // Keep the same hue + hsb[1] * 0.7f, // Reduce saturation slightly + Math.min(1.0f, hsb[2] * 1.5f), // Increase brightness + ) + return JBColor(lightVersion, this) +} + +/** + * Reduces the saturation of a color by the specified factor + * @param factor The saturation multiplier (0.0 = no saturation, 1.0 = original saturation) + */ +fun Color.withReducedSaturation(factor: Float): Color { + val hsb = Color.RGBtoHSB(red, green, blue, null) + return Color.getHSBColor( + hsb[0], // Keep the same hue + hsb[1] * factor.coerceIn(0.0f, 1.0f), // Reduce saturation by factor + hsb[2], // Keep the same brightness + ) +} + +/** + * Reduces the saturation of a color while preserving perceptual luminance + * @param factor The saturation multiplier (0.0 = no saturation, 1.0 = original saturation) + */ +fun Color.withReducedSaturationPreservingLuminance( + saturationFactor: Float, // 's' in our analysis (~0.5) +): Color { + val s = saturationFactor.coerceIn(0.0f, 1.0f) + + // Calculate luminance using ITU-R BT.709 weights (Chromium standard) + val luminance = red * 0.2126f + green * 0.7152f + blue * 0.0722f + + // Luminance-aware desaturation: L + s*(color - L) + val desatRed = (luminance + s * (red - luminance)).toInt().coerceIn(0, 255) + val desatGreen = (luminance + s * (green - luminance)).toInt().coerceIn(0, 255) + val desatBlue = (luminance + s * (blue - luminance)).toInt().coerceIn(0, 255) + + return Color(desatRed, desatGreen, desatBlue, alpha) +} + +/** + * Reduces the saturation of a color while preserving perceptual luminance with different factors for light and dark modes + * @param lightSaturationFactor The saturation multiplier for light mode (0.0 = no saturation, 1.0 = original saturation) + * @param darkSaturationFactor The saturation multiplier for dark mode (0.0 = no saturation, 1.0 = original saturation) + */ +fun Color.withReducedSaturationPreservingLuminance( + lightSaturationFactor: Float, + darkSaturationFactor: Float, +) = JBColor( + this.withReducedSaturationPreservingLuminance(lightSaturationFactor), + this.withReducedSaturationPreservingLuminance(darkSaturationFactor), +) + +/** + * Adjust brightness (HSB value) while preserving hue, saturation, and alpha. + * brightnessFactor: 0.0 = black, 1.0 = original, >1.0 = brighter + */ +fun Color.withAdjustedBrightnessPreservingHue(brightnessFactor: Float): Color { + val hsb = Color.RGBtoHSB(red, green, blue, null) + val newB = (hsb[2] * brightnessFactor).coerceIn(0.0f, 1.0f) + val rgb = Color.getHSBColor(hsb[0], hsb[1], newB) + return Color(rgb.red, rgb.green, rgb.blue, alpha) +} + +/** + * Theme-aware brightness adjustment while preserving hue and alpha. + */ +fun Color.withAdjustedBrightnessPreservingHue( + lightBrightnessFactor: Float, + darkBrightnessFactor: Float, +) = JBColor( + this.withAdjustedBrightnessPreservingHue(lightBrightnessFactor), + this.withAdjustedBrightnessPreservingHue(darkBrightnessFactor), +) + +class ShowOnHoverMouseAdaptor( + private val container: Component, + private val child: Component, + private val delay: Int = 100, + private val shouldShow: () -> Boolean = { true }, +) : MouseAdapter() { + private var isMouseOver = false + + override fun mouseEntered(e: MouseEvent) { + isMouseOver = true + if (shouldShow()) { + child.isVisible = true + container.revalidate() + container.repaint() + } + } + + override fun mouseExited(e: MouseEvent) { + isMouseOver = false + if (shouldShow() && + !child.bounds.contains(SwingUtilities.convertPoint(container, e.point, child)) + ) { + Timer(delay) { + if (!isMouseOver && shouldShow()) { + child.isVisible = false + container.revalidate() + container.repaint() + } + }.apply { + isRepeats = false + start() + } + } + } +} + +fun Container.showOnHoverMouseAdaptor( + child: Component, + delay: Int = 100, + shouldShow: () -> Boolean = { true }, +) = ShowOnHoverMouseAdaptor( + this, + child, + delay, + shouldShow, +) + +fun Iterable.filterNotEquals(item: T) = this.filterNot { it == item } + +fun Sequence.filterNotEquals(item: T) = this.filterNot { it == item } + +fun Iterable.filterEquals(item: T) = this.filter { it == item } + +fun Sequence.filterEquals(item: T) = this.filter { it == item } + +// to make it testable +var lineSeparator = { System.lineSeparator() } + +fun String.getLineSeparatorType(): String = + when { + this.contains("\r\n") -> "\r\n" + this.contains("\r") -> "\r" + this.contains("\n") -> "\n" + else -> lineSeparator() + } + +fun String.normalizeLineEndings(): String = this.replace(Regex("""\r?\n"""), lineSeparator()) + +fun String.normalizeLineEndings(referenceContent: String): String = replace(getLineSeparatorType(), referenceContent.getLineSeparatorType()) + +fun String.convertLineEndings(lineEnding: String = "\n"): String = replace(Regex("""\r?\n"""), lineEnding) + +fun String.normalizeCharacters(): String = + this + // Normalize apostrophes to standard keyboard apostrophe + .replace("’", "'") + +fun String.normalizeUsingNFC(): String = Normalizer.normalize(this, Normalizer.Form.NFC) + +fun String.platformAwareContains(str: String): Boolean { + // First try direct match + if (this.contains(str)) return true + + // Try with line ending normalization + if (this.contains(str.normalizeLineEndings(getLineSeparatorType()))) return true + + // Try with Unicode NFC normalization (handles composed vs decomposed characters like й) + val normalizedThis = this.normalizeUsingNFC() + val normalizedStr = str.normalizeUsingNFC() + return normalizedThis.contains(normalizedStr) +} + +fun String.platformAwareIndexOf( + str: String, + startIndex: Int = 0, +): Int { + // assume this is using the newline from the system but str is not + val lineSeparatorType = getLineSeparatorType() + if (lineSeparatorType !in this) return this.indexOf(str, startIndex) + val originalIndex = this.indexOf(str.normalizeLineEndings(getLineSeparatorType()), startIndex) + if (originalIndex >= 0) return originalIndex + + // Try with Unicode NFC normalization (handles composed vs decomposed characters like й) + val normalizedThis = this.normalizeUsingNFC() + val normalizedStr = str.normalizeUsingNFC() + val normalizedIndex = normalizedThis.indexOf(normalizedStr, startIndex) + + if (normalizedIndex >= 0) { + // Calculate offset: difference between original and normalized prefix lengths + val prefixBeforeMatch = this.substring(0, normalizedIndex.coerceAtMost(this.length)) + val normalizedPrefixLength = prefixBeforeMatch.normalizeUsingNFC().length + val offset = prefixBeforeMatch.length - normalizedPrefixLength + return normalizedIndex + offset + } + + return -1 +} + +/** + * Replaces the first occurrence of [oldStr] with [newStr], handling Unicode normalization differences. + * This handles cases where the same character can be represented differently (e.g., й as single char vs и + combining breve). + */ +fun String.platformAwareReplace( + oldStr: String, + newStr: String, +): String { + val normalizedThis = this.normalizeUsingNFC() + val normalizedOldStr = oldStr.normalizeUsingNFC() + val normalizedIndex = normalizedThis.indexOf(normalizedOldStr) + + if (normalizedIndex >= 0) { + // Calculate start offset: difference between original and normalized prefix lengths + val prefixBeforeMatch = this.substring(0, normalizedIndex.coerceAtMost(this.length)) + val startOffset = prefixBeforeMatch.length - prefixBeforeMatch.normalizeUsingNFC().length + val originalStartIndex = normalizedIndex + startOffset + + // Calculate end offset: difference between original and normalized lengths up to end of match + val normalizedEndIndex = normalizedIndex + normalizedOldStr.length + val prefixBeforeEnd = this.substring(0, normalizedEndIndex.coerceAtMost(this.length)) + val endOffset = prefixBeforeEnd.length - prefixBeforeEnd.normalizeUsingNFC().length + val originalEndIndex = normalizedEndIndex + endOffset + + // Replace the original substring with the new string, avoiding modifying other parts of the string + return this.substring(0, originalStartIndex) + newStr + this.substring(originalEndIndex) + } + + // No match found, return original string + return this +} + +fun Color.darker(factor: Int): Color { + var res = this + for (i in 0..factor) { + res = res.darker() + } + return res +} + +fun Color.brighter(factor: Int): Color { + var res = this + for (i in 0..factor) { + res = res.brighter() + } + return res +} + +val Project.osBasePath: String? + get() = basePath?.replace("/", File.separator) + +fun VirtualFile.readTextOnDisk(): String { + SlowOperations.assertSlowOperationsAreAllowed() + return contentsToByteArray().toString(Charsets.UTF_8) +} + +fun String.safeSlice(range: IntRange) = + slice( + range.first.coerceAtLeast(0)..range.last.coerceAtMost(length - 1), + ) + +fun List.safeSlice(range: IntRange) = + slice( + range.first.coerceAtLeast(0)..range.last.coerceAtMost(size - 1), + ) + +fun String.countSubstrings(substring: String): Int { + if (substring.isEmpty()) return 0 + var count = 0 + var index = 0 + while (index != -1) { + index = indexOf(substring, index) + if (index != -1) { + count++ + index += substring.length + } + } + return count +} + +fun Document.linesToOffsetRange(range: IntRange): TextRange { + val currentStartOffset = getLineStartOffset(range.first) + val currentEndOffset = getLineEndOffset(range.last) + return TextRange(currentStartOffset, currentEndOffset) +} + +fun IntRange.expand( + n: Int, + maxSize: Int = Int.MAX_VALUE, +) = IntRange( + (first - n).coerceAtLeast(0), + (last + n).coerceAtMost(maxSize), +) + +fun PsiElement.documentLinesRange(document: Document): TextRange = document.linesToOffsetRange(linesRange(document)) + +fun PsiElement.documentLines(document: Document) = document.getText(documentLinesRange(document)) + +fun PsiElement.findLargestParent(maxLines: Int): PsiElement { + var currentBlock = this + while ( + currentBlock.parent != null && + currentBlock.parent.numLines() <= maxLines + ) { + currentBlock = currentBlock.parent + } + return currentBlock +} + +fun PsiElement.numLines() = text.lines().size + +fun PsiElement.linesRange(document: Document): IntRange = + IntRange(document.getLineNumber(textRange.startOffset), document.getLineNumber(textRange.endOffset)) + +infix fun IntRange.distanceTo(other: Int): Int { + if (other in this) { + return 0 + } + return kotlin.math.min(abs(start - other), abs(endInclusive - other)) +} + +infix fun Int.distanceTo(other: IntRange): Int = other distanceTo this + +infix fun IntRange.distanceTo(other: IntRange) = min(this distanceTo other.first, this distanceTo other.last) + +fun hexToColor(hex: String): Color? = + try { + val cleanHex = hex.removePrefix("#") + if (cleanHex.length == 6) { + Color( + cleanHex.substring(0, 2).toInt(16), + cleanHex.substring(2, 4).toInt(16), + cleanHex.substring(4, 6).toInt(16), + ) + } else { + null + } + } catch (e: NumberFormatException) { + null + } diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileDisplayUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileDisplayUtils.kt new file mode 100644 index 0000000..006a7ca --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileDisplayUtils.kt @@ -0,0 +1,41 @@ +package com.oxidecode.utils + +import com.oxidecode.data.CompletedToolCall +import com.oxidecode.data.ToolCall + +object FileDisplayUtils { + /** + * Converts a parameter value to its display representation. + * For file paths, shows just the filename. For other values, returns as-is. + */ + fun getDisplayValueForParameter(paramValue: String): String = + if (paramValue.contains('/')) { + // This looks like a file path, show just the filename + paramValue.substringAfterLast('/') + } else { + // Not a file path, show the full value + paramValue + } + + /** + * Gets the tooltip text that shows the full path for file operations. + */ + fun getFullPathTooltip( + toolCall: ToolCall, + completedToolCall: CompletedToolCall?, + formatSingleToolCall: (ToolCall, CompletedToolCall?) -> String, + getDisplayParameterForTool: (ToolCall) -> String, + ): String { + val paramValue = getDisplayParameterForTool(toolCall) + return if (paramValue.contains('/')) { + // For file paths, show the full path in tooltip + val toolDescription = formatSingleToolCall(toolCall, completedToolCall) + // Replace the filename in the description with the full path + val displayValue = getDisplayValueForParameter(paramValue) + toolDescription.replace(displayValue, paramValue) + } else { + // For non-file operations, use the regular formatted text + formatSingleToolCall(toolCall, completedToolCall) + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileSearchUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileSearchUtils.kt new file mode 100644 index 0000000..ae8089b --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileSearchUtils.kt @@ -0,0 +1,108 @@ +package com.oxidecode.utils + +import java.io.File + +fun findLinkedWord( + keyword: String, + basePath: String?, + relativePath: String, +): Pair? { + try { + val file = File(basePath, relativePath) + if (file.exists() && file.isFile) { + val content = file.readText() + + // Determine language based on file extension + val isKotlinFile = relativePath.endsWith(".kt") || relativePath.endsWith(".kts") + val isJavaFile = relativePath.endsWith(".java") + val isPythonFile = relativePath.endsWith(".py") + val isTypeScriptFile = relativePath.endsWith(".ts") || relativePath.endsWith(".tsx") + val isCppFile = + relativePath.endsWith(".cpp") || + relativePath.endsWith(".hpp") || + relativePath.endsWith(".cc") || + relativePath.endsWith(".h") + + // Select appropriate patterns based on language + val patterns = + when { + isKotlinFile -> + listOf( + "fun\\s+${Regex.escape(keyword)}\\b", // Functions (with or without parameters) + "(class|interface|object|enum\\s+class|typealias)\\s+${Regex.escape(keyword)}\\b", // Class-like declarations + ) + isJavaFile -> + listOf( + "\\b(public|private|protected|static|final|\\s)*\\s+\\w+\\s+${Regex.escape(keyword)}\\b\\s*\\(", // Methods + "\\b(public|private|protected|static|final|\\s)*\\s+(class|interface|enum|@interface)\\s+${Regex.escape( + keyword, + )}\\b", // Type declarations + ) + isPythonFile -> + listOf( + "(async\\s+)?def\\s+${Regex.escape(keyword)}\\b(?=\\s*\\()", // Functions (including async) + "class\\s+${Regex.escape(keyword)}\\b(?=\\s*[:\\(])", // Classes + ) + isTypeScriptFile -> + listOf( + "(async\\s+)?function\\s+${Regex.escape(keyword)}\\b", // Functions (including async) + "(export\\s+)?(default\\s+)?(class|interface|enum|type)\\s+${Regex.escape(keyword)}\\b", // Type declarations + "(export\\s+)?namespace\\s+${Regex.escape(keyword)}\\b", // Namespaces + ) + isCppFile -> + listOf( + "\\b\\w+\\s+${Regex.escape(keyword)}\\b\\s*\\(", // Functions + "(class|struct|enum(\\s+class)?|namespace)\\s+${Regex.escape(keyword)}\\b", // Type declarations + "(typedef|template\\s*<.*>\\s*(class|struct))\\s+${Regex.escape(keyword)}\\b", // Typedefs and templates + "#define\\s+${Regex.escape(keyword)}\\b", // Macros + ) + else -> listOf("\\b${Regex.escape(keyword)}\\b") + } + + for (pattern in patterns) { + val regex = Regex(pattern) + val lineNumber = + content.lines().indexOfFirst { line -> + regex.containsMatchIn(line) + } + if (lineNumber >= 0) { + return Pair(relativePath, lineNumber + 1) + } + } + } + return null + } catch (e: Exception) { + return null + } +} + +fun findKeywordDirectlyInFile( + keyword: String, + basePath: String?, + relativePath: String, +): Pair? { + try { + val file = File(basePath, relativePath) + if (file.exists() && file.isFile) { + val content = file.readText() + val lines = content.lines() + + // Use word boundary regex to match whole words only + // This prevents "debu" from matching "debug_info" + val wordBoundaryRegex = Regex("\\b${Regex.escape(keyword)}\\b") + + // Find the first line containing the keyword as a whole word + val lineNumber = + lines.indexOfFirst { line -> + wordBoundaryRegex.containsMatchIn(line) + } + + if (lineNumber >= 0) { + return Pair(relativePath, lineNumber + 1) + } + } + return null + } catch (e: Exception) { + return null + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileUtils.kt new file mode 100644 index 0000000..545fe09 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FileUtils.kt @@ -0,0 +1,194 @@ +package com.oxidecode.utils + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.SlowOperations +import java.io.File +import java.net.URI +import java.nio.file.Paths + +private val logger = Logger.getInstance("com.oxidecode.utils.FileUtils") + +// URL prefixes that should be blocked from file operations +val BLOCKED_URL_PREFIXES = listOf("gitlabmr:") + +// note that this function likely doesnt do anything as apparently running the command in a subprocess doesn't +// have any effect but I will keep it in just in case +fun setSoftFileDescriptorLimit(limit: Int): Boolean { + val osName = System.getProperty("os.name").lowercase() + + return if (osName.contains("win")) { + logger.info("Setting file-descriptor limit is not supported on Windows.") + false + } else if (osName.contains("nix") || osName.contains("nux") || osName.contains("mac")) { + var process: Process? = null + try { + process = Runtime.getRuntime().exec(arrayOf("sh", "-c", "ulimit -S -n $limit")) + val exitCode = process.waitFor() + if (exitCode == 0) { + logger.info("Successfully set soft FD limit to $limit on $osName.") + true + } else { + val error = + process.errorStream + .bufferedReader() + .readText() + .trim() + logger.warn("Failed to set FD limit. Error: $error") + false + } + } catch (e: Exception) { + logger.warn("Exception while setting file descriptor limit: ${e.message}") + false + } finally { + // Always clean up the process, even if interrupted + process?.destroy() + } + } else { + logger.warn("Unsupported operating system: $osName.") + false + } +} + +fun baseNameFromPathString(path: String): String = File(path).name + +fun entityNameFromPathString(path: String): String = path.substringAfterLast("::", "") + +fun getCurrentOpenVirtualFile(project: Project): VirtualFile? = FileEditorManager.getInstance(project).selectedFiles.firstOrNull() + +fun getCurrentOpenRelativeFilePath(project: Project): String? = relativePath(project, getCurrentOpenVirtualFile(project)) + +fun safeDeleteFileOnBGT(filePath: String?) { + if (filePath == null) return + ApplicationManager.getApplication().executeOnPooledThread { + try { + val closedFileTempFile = File(filePath) + if (closedFileTempFile.exists()) { + if (closedFileTempFile.delete()) { + logger.debug( + "Successfully deleted temporary file: ${closedFileTempFile.absolutePath}", + ) + } else { + logger.warn( + "Failed to delete temporary file: ${closedFileTempFile.absolutePath}", + ) + } + } + } catch (e: Exception) { + logger.warn("Error while deleting temporary file in bgt: ${e.message}") + } + } +} + +fun safeDeleteFile(filePath: String?) { + SlowOperations.assertSlowOperationsAreAllowed() + if (filePath == null) return + try { + val closedFileTempFile = File(filePath) + if (closedFileTempFile.exists()) { + if (closedFileTempFile.delete()) { + logger.debug("deleted file ${closedFileTempFile.absolutePath}") + } + } + } catch (e: Exception) { + logger.warn("Error while deleting file: ${e.message}") + } +} + +fun getAbsolutePathFromUri(uriString: String): String? = + try { + // Normalize path separators for Windows paths before creating URI + // URIs use forward slashes, so convert backslashes to forward slashes + val normalizedPath = uriString.replace("\\", "/") + // fixes uri path with spaces + val encodedUriString = normalizedPath.replace(" ", "%20") + val uri = URI(encodedUriString) + if (uri.scheme.orEmpty().equals("file", ignoreCase = true)) { + Paths.get(uri).toAbsolutePath().toString() + } else { + null + } + } catch (e: Exception) { + if (!uriString.contains(":")) { + logger.warn("Invalid URI format: $uriString - ${e.message}") + } + null + } + +fun toAbsolutePath( + filePath: String, + project: Project, +): String? { + val projectBasePath = project.basePath + + // Return null for blocked URL prefixes + if (BLOCKED_URL_PREFIXES.any { filePath.startsWith(it, ignoreCase = true) }) { + return null + } + + // First try to handle as file:// URI + getAbsolutePathFromUri(filePath)?.let { return it } + + // Reject other URI schemes that aren't file paths + if (filePath.contains("://") && !filePath.startsWith("file://", ignoreCase = true)) { + // Log the issue instead of automatic error reporting + logger.warn("Non-file URI passed to toAbsolutePath: $filePath") + + // Safe fallback: extract everything after the first "://" and ensure no further URIs + val fallbackPath = filePath.substringAfter("://") + + // check again if fallback still contains URI scheme + if (fallbackPath.contains("://")) { + logger.warn("Fallback path still contains URI scheme, treating as regular path: $fallbackPath") + return handleRegularFilePath(fallbackPath, projectBasePath) + } + + return toAbsolutePath(fallbackPath, project) + } + + return handleRegularFilePath(filePath, projectBasePath) +} + +private fun handleRegularFilePath( + filePath: String, + projectBasePath: String?, +): String { + val absolutePath = + if (!File(filePath).isAbsolute && projectBasePath != null) { + try { + Paths.get(projectBasePath, filePath).toString() + } catch (e: Exception) { + logger.warn("Failed to create path from projectBasePath='$projectBasePath' and filePath='$filePath'", e) + filePath + } + } else { + filePath + } + + return File(absolutePath).toString() +} + +/** + * Check if a file name matches an autocomplete exclusion pattern. + * Supports `**` as a trailing wildcard for prefix matching: + * - `scratch**` matches `scratch.kt`, `scratch_test.py`, etc. + * + * Without `**`, falls back to a simple suffix check for backward compatibility: + * - `.env` matches `something.env` + */ +fun matchesExclusionPattern( + fileName: String, + pattern: String, +): Boolean { + if (pattern.isEmpty()) return false + + return if (pattern.endsWith("**")) { + val prefix = pattern.removeSuffix("**") + fileName.startsWith(prefix, ignoreCase = true) + } else { + fileName.endsWith(pattern) + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FontUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FontUtils.kt new file mode 100644 index 0000000..538ed53 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/FontUtils.kt @@ -0,0 +1,31 @@ +package com.oxidecode.utils + +import com.intellij.openapi.project.Project +import com.intellij.util.ui.JBUI +import com.oxidecode.settings.OxideCodeConfig +import java.awt.Font +import javax.swing.JComponent + +fun JComponent.withSweepFont( + project: Project, // now required + scale: Float = 1f, + bold: Boolean = false, +): JComponent { + val baseSize = + try { + OxideCodeConfig.getInstance(project).state.fontSize + } catch (e: Exception) { + JBUI.Fonts + .label() + .size + .toFloat() + } + val finalSize = baseSize * scale + font = + if (bold) { + JBUI.Fonts.label().deriveFont(Font.BOLD, finalSize) + } else { + JBUI.Fonts.label().deriveFont(finalSize) + } + return this +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/GithubUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/GithubUtils.kt new file mode 100644 index 0000000..f99997f --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/GithubUtils.kt @@ -0,0 +1,480 @@ +package com.oxidecode.utils + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import java.io.File + +private var cachedGitUserName: String? = null +private val logger = Logger.getInstance("com.oxidecode.utils.GithubUtils") + +fun parseGitUrl(url: String?): String { + if (url.isNullOrEmpty()) return "" + val trimmed = url.removeSuffix(".git").trim() + + return when { + // SSH pattern: git@github.com:owner/repo + trimmed.startsWith("git@") -> { + val repoPart = trimmed.substringAfter(":") + if (repoPart.contains("/")) repoPart else "" + } + + // HTTPS pattern: https://github.com/owner/repo + trimmed.startsWith("http://") || trimmed.startsWith("https://") -> { + val afterProto = trimmed.substringAfter("://") + val repoPart = afterProto.substringAfter("/") + if (repoPart.contains("/")) repoPart else "" + } + + else -> "" + } +} + +fun getGithubRepoName( + project: Project, + onRepoFound: (String?) -> Unit, +) { + fun checkRepository(triesLeft: Int) { + if (triesLeft <= 0) { + onRepoFound(null) + return + } + + // Move JGit operations to background thread to avoid EDT blocking + ApplicationManager.getApplication().executeOnPooledThread { + try { + val repository = findRootRepository(project) + if (repository != null) { + val result = + runCatching { + val remoteUrl = findRemoteUrl(repository) + val parsed = parseGitUrl(remoteUrl) + parsed.ifEmpty { + val workTree = repository.workTree + val parent = workTree.parentFile?.name ?: "" + val current = workTree.name + // Need the github token so paths from different users can be disambiguated. + "$parent/$current" + } + }.recoverCatching { + // Fallback to directory name if remote URL fails + findGitRootDirectory(repository).name + }.getOrNull() + + // Switch back to EDT to deliver the result + ApplicationManager.getApplication().invokeLater { + result?.let { onRepoFound(it) } + } + } else { + // Repository not yet available, check again after a short delay + Thread.sleep(500) + checkRepository(triesLeft - 1) + } + } catch (e: Exception) { + showNotification( + project, + "Error initializing repository", + e.message ?: "Unknown error occurred", + "Error Notifications", + ) + } + } + } + + checkRepository(40) // 20s +} + +fun getGitUserName(project: Project): String { + // Return cached value if available + cachedGitUserName?.let { return it } + + val basePath = project.osBasePath ?: return "You".also { cachedGitUserName = it } + return try { + var process: Process? = null + var userName: String? + + try { + process = + ProcessBuilder("git", "config", "user.fullname") + .directory(File(basePath)) + .start() + userName = process.inputStream.bufferedReader().use { it.readLine()?.trim() } + process.waitFor() + } finally { + process?.destroy() + } + + if (userName.isNullOrBlank()) { + process = null + try { + process = + ProcessBuilder("git", "config", "user.name") + .directory(File(basePath)) + .start() + userName = process.inputStream.bufferedReader().use { it.readLine()?.trim() } + process.waitFor() + } finally { + process?.destroy() + } + } + + (userName?.takeIf { it.isNotBlank() } ?: "You").also { + cachedGitUserName = it + } + } catch (e: Exception) { + "You".also { cachedGitUserName = it } + } +} + +@RequiresBackgroundThread +fun findRootRepository(project: Project): Repository? { + val start = project.osBasePath?.let { File(it) } ?: return null + return runCatching { + FileRepositoryBuilder() + .setWorkTree(start) + .findGitDir(start) + .build() + }.getOrNull() +} + +fun findGitRootDirectory(repo: Repository): File = repo.directory.parentFile + +fun findRemoteUrl( + repo: Repository, + remoteName: String? = null, +): String? { + repo.use { repository -> + if (remoteName != null) { + repository.config.getString("remote", remoteName, "url")?.let { return it } + } else { + repository.config.getString("remote", "origin", "url")?.let { return it } + + repository.config.getSubsections("remote").firstOrNull()?.let { firstRemote -> + return repository.config.getString("remote", firstRemote, "url") + } + } + + return null + } +} + +@RequiresBackgroundThread +fun findGitRepositoriesRecursively( + directory: File, + maxDepth: Int = 3, +): List { + if (maxDepth <= 0) return emptyList() + + val repositories = mutableListOf() + + val gitDir = File(directory, ".git") + if (gitDir.exists() && gitDir.isDirectory) { + runCatching { + FileRepositoryBuilder() + .setGitDir(gitDir) + .setWorkTree(directory) + .build() + }.onSuccess { repo -> + repositories.add(repo) + return repositories // If found, don't search deeper + } + } + + directory + .listFiles() + ?.filter { it.isDirectory && it.name != ".git" } + ?.forEach { subDir -> + repositories.addAll(findGitRepositoriesRecursively(subDir, maxDepth - 1)) + } + + return repositories +} + +data class GitIgnoredPaths( + val files: Set, + val directories: Set, +) + +@Deprecated("Use Intellij APIs instead like ProjectFileIndex.isInContent()") +@RequiresBackgroundThread +fun gitIgnoredPaths(project: Project): GitIgnoredPaths { + val projectDir = project.osBasePath?.let { File(it) } ?: return GitIgnoredPaths(emptySet(), emptySet()) + + val repositories = findGitRepositoriesRecursively(projectDir) + if (repositories.isEmpty()) return GitIgnoredPaths(emptySet(), emptySet()) + + val allFiles = mutableSetOf() + val allDirectories = mutableSetOf() + + repositories.forEach { repo -> + repo.use { repository -> + val git = Git(repository) + val status = git.status().call() + val ignoredFiles = status.ignoredNotInIndex + val workTree = repository.workTree + val projectPath = projectDir.toPath() + + for (path in ignoredFiles) { + val absolutePath = File(workTree, path) + val relativePath = projectPath.relativize(absolutePath.toPath()).toString() + if (absolutePath.isDirectory) { + allDirectories.add(relativePath) + } else { + allFiles.add(relativePath) + } + } + } + } + return GitIgnoredPaths(allFiles, allDirectories) +} + +/** + * Gets the current branch name synchronously. + * WARNING: This function performs blocking I/O operations and should not be called from the EDT. + * Use getCurrentBranchNameAsync() for EDT-safe operation. + */ +@RequiresBackgroundThread +fun getCurrentBranchName(project: Project): String? { + // Try JGit approach first + val jgitResult = + try { + val repository = findRootRepository(project) ?: return null + repository.use { repo -> + repo.branch + } + } catch (e: InterruptedException) { + // Thread was interrupted, return null gracefully + logger.debug("Thread interrupted while getting current branch name", e) + Thread.currentThread().interrupt() // Restore interrupt status + return null + } catch (e: Exception) { + null + } + + // If JGit approach failed, try using ProcessBuilder + if (jgitResult == null) { + val basePath = project.osBasePath ?: return null + return try { + var process: Process? = null + try { + process = + ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD") + .directory(File(basePath)) + .start() + val branchName = process.inputStream.bufferedReader().use { it.readLine()?.trim() } + process.waitFor() + branchName.takeIf { !it.isNullOrBlank() } + } finally { + process?.destroy() + } + } catch (e: InterruptedException) { + // Thread was interrupted, return null gracefully + logger.debug("Thread interrupted while getting current branch name via git command", e) + Thread.currentThread().interrupt() // Restore interrupt status + null + } catch (e: Exception) { + null + } + } + return jgitResult +} + +/** + * Gets the current branch name asynchronously to avoid blocking the EDT. + * Executes the branch name retrieval in a background thread and calls the callback with the result. + * + * @param project The current project + * @param callback Function to call with the branch name result (null if failed) + */ +fun getCurrentBranchNameAsync( + project: Project, + callback: (String?) -> Unit, +) { + ApplicationManager.getApplication().executeOnPooledThread { + try { + val branchName = getCurrentBranchName(project) + ApplicationManager.getApplication().invokeLater { + callback(branchName) + } + } catch (e: Exception) { + logger.warn("Failed to get current branch name", e) + ApplicationManager.getApplication().invokeLater { + callback(null) + } + } + } +} + +@RequiresBackgroundThread +fun getRecentCommitMessages( + project: Project, + maxCount: Int = 10, +): List { + val userName = getGitUserName(project) + + // Try JGit approach first + val jgitResult = + try { + val repository = findRootRepository(project) ?: return emptyList() + repository.use { repo -> + val git = Git(repo) + git + .log() + .setMaxCount(maxCount * 2) // Fetch more commits since we'll filter some out + .call() + .filter { it.authorIdent.name == userName } + .take(maxCount) + .map { it.shortMessage.trim() } + .toList() + } + } catch (e: InterruptedException) { + // Thread was interrupted, return empty list gracefully + logger.debug("Thread interrupted while getting recent commit messages", e) + Thread.currentThread().interrupt() // Restore interrupt status + return emptyList() + } catch (e: Exception) { + null + } + + // If JGit approach failed, try using ProcessBuilder + if (jgitResult == null) { + val basePath = project.osBasePath ?: return emptyList() + return try { + var process: Process? = null + try { + process = + ProcessBuilder( + "git", + "log", + "--author=" + userName, // Filter by author + "--pretty=format:%s", // format to only show commit messages + "-n", // limit number of commits + maxCount.toString(), + ).directory(File(basePath)) + .start() + + process.inputStream + .bufferedReader() + .useLines { lines -> + lines + .map { it.trim() } + .filter { it.isNotBlank() } + .take(maxCount) + .toList() + }.also { process.waitFor() } + } finally { + process?.destroy() + } + } catch (e: InterruptedException) { + // Thread was interrupted, return empty list gracefully + logger.debug("Thread interrupted while getting recent commit messages via git command", e) + Thread.currentThread().interrupt() // Restore interrupt status + emptyList() + } catch (e: Exception) { + emptyList() + } + } + return jgitResult +} + +enum class GitChangeType { + ADDED, + CHANGED, + MODIFIED, + REMOVED, + UNTRACKED, + CONFLICTING, +} + +fun getUncommittedChanges(project: Project): Map> { + var uncommittedFiles = emptyMap>() + + try { + val repository = findRootRepository(project) ?: return uncommittedFiles + + repository.use { repo -> + val git = Git(repo) + val status = git.status().call() + + uncommittedFiles = + mapOf( + GitChangeType.ADDED to status.added.toList(), + GitChangeType.CHANGED to status.changed.toList(), + GitChangeType.MODIFIED to status.modified.toList(), + GitChangeType.REMOVED to status.removed.toList(), + GitChangeType.UNTRACKED to status.untracked.toList(), + GitChangeType.CONFLICTING to status.conflicting.toList(), + ) + } + } catch (e: Exception) { + logger.warn("Failed to get uncommitted files", e) + } + + return uncommittedFiles +} + +/** + * Checks if a file is a Git LFS file by looking for the LFS pointer format + */ +fun isGitLfsFile(file: File): Boolean = + try { + // Check for Git LFS pointer file format + val firstLine = file.bufferedReader().use { it.readLine() } + firstLine?.startsWith("version https://git-lfs.github.com/spec/") == true + } catch (e: Exception) { + false + } + +/** + * Generically untrack a file in the .idea directory from VCS and add it to .gitignore + * @param project The current project + * @param fileName The name of the file to untrack (e.g., "GhostTextManager_v2.xml") + */ +fun untrackIdeaFile( + project: Project, + fileName: String, +) { + ApplicationManager.getApplication().executeOnPooledThread { + try { + val repository = findRootRepository(project) ?: return@executeOnPooledThread + + repository.use { repo -> + val git = Git(repo) + val workTree = repo.workTree + val projectPath = project.basePath?.let { File(it) } ?: return@executeOnPooledThread + + // Check if the file exists in any .idea directory + val ideaDirectories = + arrayOf( + File(projectPath, ".idea"), + File(workTree, ".idea"), + ).filter { it.exists() && it.isDirectory } + + for (ideaDir in ideaDirectories) { + val targetFile = File(ideaDir, fileName) + if (targetFile.exists()) { + val relativePath = workTree.toPath().relativize(targetFile.toPath()).toString() + + // Remove from Git tracking if it's currently tracked + try { + git + .rm() + .addFilepattern(relativePath) + .setCached(true) + .call() + } catch (e: Exception) { + // File might not be tracked, which is fine + } + } + } + } + } catch (e: Exception) { + // Silently handle any Git operation failures + logger.debug("Failed to untrack $fileName", e) + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/HighlightingUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/HighlightingUtils.kt new file mode 100644 index 0000000..1297fdc --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/HighlightingUtils.kt @@ -0,0 +1,66 @@ +package com.oxidecode.utils + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiNameIdentifierOwner +import com.intellij.psi.util.PsiTreeUtil +import com.oxidecode.theme.OxideCodeColors.semanticColors +import java.awt.Color +import java.awt.Font + +fun applySemanticHighlighting( + project: Project, + editor: Editor, + virtualFile: VirtualFile, +) { + val application = ApplicationManager.getApplication() + if (!application.isReadAccessAllowed) { + application.runReadAction { applySemanticHighlighting(project, editor, virtualFile) } + return + } + + val variableHighlights = mutableMapOf() + val highlightRangeMarkers = mutableListOf() + + val psiFile = PsiManager.getInstance(project).findFile(virtualFile) ?: return + var colorIndex = 0 + + val variableNames = mutableSetOf() + PsiTreeUtil.processElements(psiFile) { element -> + if (element is PsiNameIdentifierOwner) { + element.name?.let { variableNames.add(it) } + } + true + } + + val text = editor.document.text + val variablePattern = "\\b[a-zA-Z_]\\w*\\b".toRegex() + val matches = variablePattern.findAll(text) + + matches.forEach { match -> + val name = match.value + if (variableNames.contains(name)) { + if (!variableHighlights.containsKey(name)) { + variableHighlights[name] = semanticColors[colorIndex] + colorIndex = (colorIndex + 1) % semanticColors.size + } + + val highlighter = + editor.markupModel.addRangeHighlighter( + match.range.first, + match.range.last + 1, + HighlighterLayer.ADDITIONAL_SYNTAX, + TextAttributes(variableHighlights[name]!!, null, null, null, Font.PLAIN), + HighlighterTargetArea.EXACT_RANGE, + ) + highlightRangeMarkers.add(highlighter) + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/LRUCache.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/LRUCache.kt new file mode 100644 index 0000000..c16296e --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/LRUCache.kt @@ -0,0 +1,222 @@ +package com.oxidecode.utils + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +/** + * A thread-safe LRU cache with TTL-based eviction and configurable max size. + * + * Implementation note: The removeLRU() method currently has O(n) time complexity + * because it needs to find the key for the tail entry by searching through the + * ConcurrentHashMap entries. This could be optimized by maintaining a reverse + * mapping from CacheEntry to key, but the current implementation prioritizes + * simplicity and memory efficiency over this edge case performance. + * + * @param K the type of keys maintained by this cache + * @param V the type of mapped values + * @param maxSize the maximum number of entries to keep in the cache + * @param ttlMs the time-to-live for cache entries in milliseconds + */ +class LRUCache( + private val maxSize: Int, + private val ttlMs: Long, +) { + private data class CacheEntry( + val value: V, + val timestamp: Long, + var prev: CacheEntry? = null, + var next: CacheEntry? = null, + ) + + private val cache = ConcurrentHashMap>() + private val lock = ReentrantReadWriteLock() + + // Doubly linked list for LRU ordering + private var head: CacheEntry? = null + private var tail: CacheEntry? = null + + /** + * Retrieves a value from the cache if it exists and hasn't expired. + * Updates the entry's position to mark it as recently used. + */ + fun get(key: K): V? = + lock.write { + val entry = cache[key] ?: return null + + // Check if entry has expired + if (isExpired(entry)) { + removeEntry(key, entry) + return null + } + + // Move to head (most recently used) + moveToHead(entry) + return entry.value + } + + /** + * Stores a key-value pair in the cache. + * If the cache exceeds maxSize, removes the least recently used entry. + */ + fun put( + key: K, + value: V, + ) = lock.write { + val existingEntry = cache[key] + + if (existingEntry != null) { + // Update existing entry + val newEntry = CacheEntry(value, System.currentTimeMillis()) + cache[key] = newEntry + + // Replace in linked list + replaceEntry(existingEntry, newEntry) + moveToHead(newEntry) + } else { + // Add new entry + val newEntry = CacheEntry(value, System.currentTimeMillis()) + cache[key] = newEntry + addToHead(newEntry) + + // Check size limit + if (cache.size > maxSize) { + removeLRU() + } + } + + // Clean up expired entries periodically + if (cache.size % 10 == 0) { + cleanupExpired() + } + } + + /** + * Removes a specific key from the cache. + */ + fun remove(key: K): V? = + lock.write { + val entry = cache.remove(key) ?: return null + removeFromList(entry) + return entry.value + } + + /** + * Clears all entries from the cache. + */ + fun clear() = + lock.write { + cache.clear() + head = null + tail = null + } + + /** + * Returns the current size of the cache. + */ + fun size(): Int = + lock.read { + cache.size + } + + /** + * Checks if the cache contains a specific key (and the entry hasn't expired). + */ + fun containsKey(key: K): Boolean = + lock.read { + val entry = cache[key] ?: return false + return !isExpired(entry) + } + + /** + * Returns a snapshot of all non-expired keys in the cache. + */ + fun keys(): Set = + lock.read { + cache.entries + .filter { !isExpired(it.value) } + .map { it.key } + .toSet() + } + + private fun isExpired(entry: CacheEntry): Boolean = System.currentTimeMillis() - entry.timestamp > ttlMs + + private fun removeEntry( + key: K, + entry: CacheEntry, + ) { + cache.remove(key) + removeFromList(entry) + } + + private fun moveToHead(entry: CacheEntry) { + removeFromList(entry) + addToHead(entry) + } + + private fun addToHead(entry: CacheEntry) { + entry.prev = null + entry.next = head + + head?.prev = entry + head = entry + + if (tail == null) { + tail = entry + } + } + + private fun removeFromList(entry: CacheEntry) { + if (entry.prev != null) { + entry.prev!!.next = entry.next + } else { + head = entry.next + } + + if (entry.next != null) { + entry.next!!.prev = entry.prev + } else { + tail = entry.prev + } + + entry.prev = null + entry.next = null + } + + private fun replaceEntry( + oldEntry: CacheEntry, + newEntry: CacheEntry, + ) { + newEntry.prev = oldEntry.prev + newEntry.next = oldEntry.next + + oldEntry.prev?.next = newEntry + oldEntry.next?.prev = newEntry + + if (head == oldEntry) head = newEntry + if (tail == oldEntry) tail = newEntry + } + + private fun removeLRU() { + val lru = tail ?: return + + // Find the key for this entry + val keyToRemove = cache.entries.find { it.value == lru }?.key + keyToRemove?.let { removeEntry(it, lru) } + } + + private fun cleanupExpired() { + val currentTime = System.currentTimeMillis() + val expiredKeys = + cache.entries + .filter { currentTime - it.value.timestamp > ttlMs } + .map { it.key } + + expiredKeys.forEach { key -> + cache[key]?.let { entry -> + removeEntry(key, entry) + } + } + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeBundle.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeBundle.kt new file mode 100644 index 0000000..ab9fbeb --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeBundle.kt @@ -0,0 +1,27 @@ +package com.oxidecode.utils + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey +import java.util.function.Supplier + +@NonNls +private const val BUNDLE = "messages.oxidecode" + +object OxideCodeBundle { + private fun getBundle(): DynamicBundle = DynamicBundle(OxideCodeBundle::class.java, BUNDLE) + + @JvmStatic + @Nls + fun message( + @PropertyKey(resourceBundle = BUNDLE) key: String, + vararg params: Any, + ): String = getBundle().getMessage(key, *params) + + @JvmStatic + fun messagePointer( + @PropertyKey(resourceBundle = BUNDLE) key: String, + vararg params: Any, + ): Supplier<@Nls String> = getBundle().getLazyMessage(key, *params) +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeConstants.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeConstants.kt new file mode 100644 index 0000000..5fb85c5 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeConstants.kt @@ -0,0 +1,2705 @@ +package com.oxidecode.utils + +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.util.SystemInfo +import com.intellij.ui.JBColor +import com.oxidecode.theme.OxideCodeColors +import java.awt.Color +import javax.swing.text.html.StyleSheet + +object OxideCodeConstants { + const val PLUGIN_ID = "com.oxidecode" + const val PLUGIN_ID_KEY = "plugin.id" + val META_KEY = if (SystemInfo.isMac) "⌘" else "Ctrl " + + enum class GatewayMode { + CLIENT, + HOST, + NA, + } + + val GATEWAY_MODE: GatewayMode = + when { + System.getProperty("intellij.platform.product.mode") == "frontend" -> GatewayMode.CLIENT + System.getProperty("ide.started.from.remote.dev.launcher") == "true" -> GatewayMode.HOST + else -> GatewayMode.NA + } + + val IS_FRONTEND_MODE = GATEWAY_MODE == GatewayMode.CLIENT + val IS_BACKEND_MODE = GATEWAY_MODE == GatewayMode.HOST + + val TOOLWINDOW_NAME = "Sweep AI" + const val NEW_CHAT = "New Chat" + const val FILE_PLACEHOLDER = "" + const val GENERAL_TEXT_SNIPPET_PREFIX = "SweepCustomGeneralTextSnippet-" + const val SUGGESTED_GENERAL_TEXT_SNIPPET_PREFIX = "SweepCustomGeneralTextSnippetSuggested-" + const val GENERAL_TEXT_SNIPPET_SEPARATOR = "_" + const val CURSOR = "█" + val CUSTOM_FILE_INFO_MAP = + mapOf( + "${GENERAL_TEXT_SNIPPET_PREFIX}TerminalOutput" to "Terminal Output", + "${GENERAL_TEXT_SNIPPET_PREFIX}ConsoleOutput" to "Console Output", + "${GENERAL_TEXT_SNIPPET_PREFIX}CopyPaste" to "Pasted Content", + "${GENERAL_TEXT_SNIPPET_PREFIX}CurrentChanges" to "Current Changes", + "${GENERAL_TEXT_SNIPPET_PREFIX}ProblemsOutput" to "Problems", + ) + + const val TOKEN_TO_CHARACTERS_RATIO = 3 + const val MAX_USER_MESSAGE_INPUT_LENGTH = TOKEN_TO_CHARACTERS_RATIO * 20_000 + const val MAX_SNIPPET_CONTENT_LENGTH = TOKEN_TO_CHARACTERS_RATIO * 10_000 + + const val MAX_FILE_SIZE_MB = 2.56 + const val MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 // 2.56MB in bytes + const val MAX_REQUEST_SIZE_BYTES = 32 * 1024 * 1024 // 32MB + + val diffFiles = setOf("TabPreviewDiffVirtualFile", "Diff") + + object Styles { + val body = + """ + body { + color: #${OxideCodeColors.foregroundColorHex}; + line-height: 1.4; + hanging-punctuation: first; + text-wrap: pretty; + word-spacing: 0.05em; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + """.trimIndent() + + val message = + """ + .message-label { + font-weight: bold; + margin-bottom: 4px; + color: #${OxideCodeColors.foregroundColorHex}; + } + """.trimIndent() + + val pre = + """ + pre { + font-family: 'JetBrains Mono', 'Microsoft YaHei', 'SimHei', 'PingFang SC', 'Hiragino Sans GB', monospace; + background-color: #${OxideCodeColors.editorBackgroundColorHex}; + padding: 8px; + white-space: pre-wrap; + width: 100%; + margin: 4px 2px; + color: #${OxideCodeColors.codeExplanationDisplayTextColor}; + } + h1 pre, h2 pre { + color: #${OxideCodeColors.foregroundColorHex}; + } + """.trimIndent() + + val darkModePre = + """ + pre { + font-family: 'JetBrains Mono', 'Microsoft YaHei', 'SimHei', 'PingFang SC', 'Hiragino Sans GB', monospace; + background-color: #${OxideCodeColors.editorBackgroundColorHex}; + padding: 8px; + white-space: pre-wrap; + width: 100%; + margin: 4px 2px; + color: #9b7eb6; + } + h1 pre, h2 pre { + color: #a0a0a0; + } + """.trimIndent() + + val list = + """ + ul, ol { + margin: 2px 2px 4px 2px; + padding-left: 24px; + } + """.trimIndent() + + val listItem = + """ + li { + margin: 2px 2px; + } + """.trimIndent() + + val unorderListItem = + """ + ul li { + list-style-type: disc; + } + """.trimIndent() + + val orderedListItem = + """ + ol li { + list-style-type: decimal; + } + """.trimIndent() + + val heading1 = + """ + h1 { + font-size: 16px; + margin: 8px 0 4px 0; + font-weight: bold; + } + """.trimIndent() + + val heading2 = + """ + h2 { + font-size: 14px; + margin: 8px 0 4px 0; + font-weight: bold; + } + """.trimIndent() + + val heading3 = + """ + h3 { + font-size: 13px; + margin: 8px 0 4px 0; + font-weight: bold; + } + """.trimIndent() + + val paragraph = + """ + p { + margin: 4px 0px 8px 0px; + } + """.trimIndent() + + val listParagraph = + """ + li p { + margin: 0px 2px; + } + """.trimIndent() + + val code = + """ + code { + font-family: 'JetBrains Mono', 'Microsoft YaHei', 'SimHei', 'PingFang SC', 'Hiragino Sans GB', monospace; + color: #${OxideCodeColors.foregroundColorHex}; + background-color: #${OxideCodeColors.backgroundColorHex}; + padding: 2px 4px; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + h1 code, h2 code { + color: #${OxideCodeColors.foregroundColorHex}; + background-color: #${OxideCodeColors.backgroundColorHex}; + padding: 2px 4px; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + """.trimIndent() + + val darkModeCode = + """ + code { + font-family: 'JetBrains Mono', 'Microsoft YaHei', 'SimHei', 'PingFang SC', 'Hiragino Sans GB', monospace; + color: #${OxideCodeColors.foregroundColorHex}; + background-color: #${OxideCodeColors.backgroundColorHex}; + padding: 2px 4px; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + h1 code, h2 code { + color: #${OxideCodeColors.foregroundColorHex}; + background-color: #${OxideCodeColors.backgroundColorHex}; + padding: 2px 4px; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + """.trimIndent() + + val bold = + """ + b, strong { + font-weight: 700; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + """.trimIndent() + + val link = + """ + a { + text-decoration: underline; + font-weight: 500; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + a:hover { + opacity: 0.8; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + """.trimIndent() + + val table = + """ + table { + border-collapse: collapse; + border-spacing: 0; + margin: 8px 0; + width: 100%; + } + th, td { + text-align: left; + border: 1px solid #${OxideCodeColors.foregroundColorHex}20; + padding: 6px 8px; + } + th { + font-weight: bold; + } + """.trimIndent() + + val stylesheet = + StyleSheet().apply { + addRule(body) + addRule(message) + addRule(pre) + addRule(list) + addRule(listItem) + addRule(unorderListItem) + addRule(orderedListItem) + addRule(heading1) + addRule(heading2) + addRule(heading3) + addRule(paragraph) + addRule(listParagraph) + addRule(code) + addRule(bold) + addRule(link) + addRule(table) + } + + val darkModeStyleSheet = + StyleSheet().apply { + addRule(body) + addRule(message) + addRule(darkModePre) + addRule(list) + addRule(listItem) + addRule(unorderListItem) + addRule(orderedListItem) + addRule(heading1) + addRule(heading2) + addRule(heading3) + addRule(paragraph) + addRule(listParagraph) + addRule(darkModeCode) + addRule(bold) + addRule(link) + addRule(table) + } + } + + // Code highlight colors + val ADDED_CODE_COLOR = JBColor(Color(45, 136, 59, 51), Color(45, 136, 59, 51)) // rgba(45, 136, 59, 0.20) + + val REMOVED_CODE_COLOR = JBColor(Color(250, 56, 54, 51), Color(250, 56, 54, 51)) // rgba(250, 56, 54, 0.20) + + // Global accept/reject button colors (used in both global and per-block buttons) + val GLOBAL_ACCEPT_BUTTON_COLOR = JBColor(0x5000AA00, 0x5000BB00) // Moderately bright green with balanced opacity + val GLOBAL_REJECT_BUTTON_COLOR = OxideCodeColors.sendButtonColor + + val FILE_MENTION_HIGHLIGHT_COLOR = + JBColor( + java.awt.Color(0, 0, 0, 30), // Light mode: black with low alpha + java.awt.Color(255, 255, 255, 30), // Dark mode: white with low alpha + ) + + val LANGUAGE_EXTENSIONS = + mapOf( + "kotlin" to listOf("kt", "kts"), + "java" to listOf("java"), + "python" to listOf("py", "pyw", "pyi"), + "javascript" to listOf("js", "jsx", "mjs"), + "typescript" to listOf("ts", "tsx"), + "c" to listOf("c", "h"), + "cpp" to listOf("cpp", "hpp", "cc", "hh"), + "csharp" to listOf("cs"), + "go" to listOf("go"), + "rust" to listOf("rs"), + "swift" to listOf("swift"), + "ruby" to listOf("rb"), + "php" to listOf("php"), + "html" to listOf("html", "htm"), + "css" to listOf("css"), + "scala" to listOf("scala"), + "dart" to listOf("dart"), + "r" to listOf("r"), + "shell" to listOf("sh", "bash"), + "sql" to listOf("sql"), + "markdown" to listOf("md", "markdown"), + "documentation" to listOf("", "txt"), + "json" to listOf("json"), + "yaml" to listOf("yml", "yaml"), + "xml" to listOf("xml"), + "dockerfile" to listOf("dockerfile"), + "groovy" to listOf("groovy"), + "perl" to listOf("pl", "pm"), + "lua" to listOf("lua"), + "vue" to listOf("vue"), + "matlab" to listOf("m"), + ) + + val AVAILABLE_TOOL_FLAVOR_TEXT = + mapOf( + "list_files" to "Listing: ", + "read_file" to "Reading: ", + "create_file" to "Creating: ", + "str_replace" to "Editing: ", + "search_files" to "Searching: ", + "web_search" to "Web searching: ", + "web_fetch" to "Fetching: ", + "glob" to "Finding files: ", + "find_usages" to "Finding usages of: ", + "get_errors" to "Checking for problems in file: ", + "prompt_crunching" to "Compacting context...", + "update_action_plan" to "Creating plan", + "bash" to "Running: ", + "powershell" to "Running: ", + "notebook_edit" to "Editing: ", + "multi_str_replace" to "Editing: ", + "apply_patch" to "Applying patch", + ) + + val AVAILABLE_TOOL_FLAVOR_TEXT_FOR_GLOWING_CURSOR = + mapOf( + "list_files" to "Listing files ", + "read_file" to "Reading file ", // add spacing to make it look smoother + "create_file" to "Creating file ", + "str_replace" to "Editing file", + "search_files" to "Searching files ", + "web_search" to "Searching web", + "web_fetch" to "Fetching web page", + "glob" to "Finding files", + "find_usages" to "Finding usages", + "get_errors" to "Checking for problems", + "prompt_crunching" to "Compacting context", + "update_action_plan" to "Creating plan", + "bash" to "Running bash command", + "powershell" to "Running powershell command", + "notebook_edit" to "Editing", + "multi_str_replace" to "Editing", + "apply_patch" to "Applying patch", + ) + + val AVAILABLE_COMPLETED_TOOL_FLAVOR_TEXT = + mapOf( + "list_files" to "Listed:", + "read_file" to "Read:", + "create_file" to "Created:", + "str_replace" to "Edited:", + "search_files" to "Searched:", + "web_search" to "Web searched:", + "web_fetch" to "Fetched:", + "glob" to "Searched file paths:", + "find_usages" to "Found usages:", + "get_errors" to "Checked:", + "prompt_crunching" to "Finished compacting context.", + "update_action_plan" to "Updated plan", + "apply_patch" to "Applied patch", + "bash" to " ", // for more reading space + "powershell" to " ", // for more reading space + "notebook_edit" to "", + "multi_str_replace" to "", + ) + val AVAILABLE_FAILED_TOOL_FLAVOR_TEXT = + mapOf( + "list_files" to "Failed to list files in directory:", + "read_file" to "Failed to read:", + "create_file" to "Failed to create:", + "str_replace" to "Failed to edit:", + "search_files" to "Failed to search:", + "web_search" to "Failed to search the web for:", + "web_fetch" to "Failed to fetch from web:", + "glob" to "Failed to find files:", + "find_usages" to "Failed to find usages:", + "get_errors" to "Failed to check for problems:", + "prompt_crunching" to "Failed to compact context. We recommend starting a new chat ($META_KEY+N)", + "update_action_plan" to "Failed to update plan:", + "bash" to "Failed to execute bash command:", + "powershell" to "Failed to execute powershell command:", + "notebook_edit" to "Failed to edit:", + "multi_str_replace" to "Failed to edit:", + "apply_patch" to "Failed to apply patch", + ) + + val CODE_FILES = + setOf( + ".py", + ".rs", + ".go", + ".kt", + ".kts", + ".java", + ".scala", + ".sc", + ".sbt", + ".cpp", + ".cc", + ".c", + ".h", + ".hpp", + ".cxx", + ".cs", + ".js", + ".jsx", + ".ts", + ".tsx", + ) + + val OTHER_IMPORTANT_FILES = + setOf( + ".gitignore", + "DockerFile", + ".sh", + ".html", + ".css", + ".scss", + ) + + val EXTENSION_TO_LANGUAGE: Map = + LANGUAGE_EXTENSIONS.entries + .flatMap { (language, extensions) -> + extensions.map { extension -> extension to language } + }.toMap() + + // Onboarding constants + const val FILE_CONTEXT_USAGE_CHATS_SENT = 6 // file context - first feature to show + const val CHAT_HISTORY_CHATS_SENT = 20 // show chat history - mid-level feature + + // Gateway onboarding constants + const val GATEWAY_CLIENT_ONBOARDING_TITLE = "Sweep AI - Incorrect Plugin for Gateway Client" + const val GATEWAY_HOST_ONBOARDING_TITLE = "Sweep AI - Incorrect Plugin for Gateway Host" + + val GATEWAY_CLIENT_ONBOARDING_MESSAGE = + """ + +

This plugin will not work with JetBrains Gateway. Please install the Sweep Remote Gateway Client plugin instead.

+

Docs: https://docs.sweep.dev/gateway

+ + """.trimIndent() + + val GATEWAY_HOST_ONBOARDING_MESSAGE = + """ + +

This plugin will not work with JetBrains Gateway. Please install the Sweep Remote Gateway Host plugin instead.

+

Docs: https://docs.sweep.dev/gateway

+ + """.trimIndent() + + const val FILE_CONTEXT_USAGE_COUNT_THRESHOLD = 3 + const val STORED_FILES_TIMEOUT = 2 * 24 * 60 * 60 * 1000L // 2 days + const val MAX_RECENT_CONVERSATIONS = 200 + const val AGENT_MODE_DOCS = "https://docs.sweep.dev/agent#using-agent" + const val DEFAULT_CHAT_PLACEHOLDER = "Build or Search with Sweep - Type @ to reference files" + const val PLAN_MODE_CHAT_PLACEHOLDER = "Plan with Sweep - Type @ to reference files" + const val CONTINUE_PLANNING_PLACEHOLDER = "Tell Sweep what to change" + + // Chat tips shown after 5 user messages + val CHAT_TIPS = + listOf( + "Tip: \"AI Code Review\" is available in \"Search Everywhere\" (press Shift Shift)", + "Tip: Press Shift + Tab to enter plan mode", + "Tip: Create new chats (${META_KEY}+N) when starting new tasks", + "Tip: Sent messages will be queued while the Agent is running", + "Tip: Select code in the editor and press ${META_KEY}+J to add it to chat", + "Tip: Use ${META_KEY}+J to toggle the chat window open / closed", + "Tip: Click + drag files into the chat box to add them to the conversation", + "Tip: Add terminal outputs to chat using ${META_KEY}+J or @terminal", + "Tip: Use @Current Changes to have Sweep review your current changes", + "Tip: Go to Settings -> Advanced to have Sweep play sounds when finished", + "Tip: Configure autocomplete to ignore certain files in Settings -> Advanced", + "Tip: Click the globe to have Sweep read web links and search the web", + ) + + // Button text constants + const val SEND_BUTTON_TEXT = "" // Icon + const val RUN_PLAN_BUTTON_TEXT = "" // Icon + const val CONTINUE_PLANNING_BUTTON_TEXT = "" // Icon + const val CLEAR_CONTEXT_AND_RUN_PLAN_BUTTON_TEXT = "" // Icon + + // Backend constants + const val REQUEST_CANCELLED_BY_USER = "Rejected: Request cancelled by user" + + // Plugin IDs + val FULL_LINE_PLUGIN_ID = PluginId.getId("org.jetbrains.completion.full.line") + val COPILOT_PLUGIN_ID = PluginId.getId("com.github.copilot") + val TABNINE_PLUGIN_ID = PluginId.getId("com.tabnine.TabNine") + val WINDSURF_PLUGIN_ID = PluginId.getId("com.codeium.intellij") + val AI_ASSISTANT_PLUGIN_ID = PluginId.getId("com.intellij.ml.llm") + + // AI_ASSISTANT_PLUGIN_ID is first to break dependency chains before unloading other plugins + // Using listOf to preserve order - important for unloading plugins with dependencies + val PLUGINS_TO_DISABLE = + listOf(AI_ASSISTANT_PLUGIN_ID, COPILOT_PLUGIN_ID, TABNINE_PLUGIN_ID, WINDSURF_PLUGIN_ID, FULL_LINE_PLUGIN_ID) + + val PLUGIN_ID_TO_NAME = + mapOf( + FULL_LINE_PLUGIN_ID to "Jetbrains Local Completion", + COPILOT_PLUGIN_ID to "GitHub Copilot", + TABNINE_PLUGIN_ID to "Tabnine", + WINDSURF_PLUGIN_ID to "Windsurf", + AI_ASSISTANT_PLUGIN_ID to "JetBrains AI Assistant", + ) + + const val AGENT_ACTION_RESULT_UI_MAX_LENGTH = 20000 + const val AGENT_ACTION_RESULT_BACKEND_MAX_LENGTH = 25000 + + @Deprecated("UI no longer depends on a special content marker; rendering is driven by tool call completion events.") + const val SPECIAL_TOOL_CALL_TAG = "" + + // Search and filtering constants + const val COMMON_SYMBOLS_REGEX = "[{}();,=\\[\\]<>\"'`]" + + val KOTLIN_KEYWORDS = + listOf( + "abstract", + "actual", + "annotation", + "as", + "break", + "by", + "catch", + "class", + "companion", + "const", + "constructor", + "continue", + "crossinline", + "data", + "delegate", + "do", + "dynamic", + "else", + "enum", + "expect", + "external", + "false", + "field", + "file", + "final", + "finally", + "for", + "fun", + "get", + "if", + "import", + "in", + "infix", + "init", + "inline", + "inner", + "interface", + "internal", + "is", + "it", + "lateinit", + "noinline", + "null", + "object", + "open", + "operator", + "out", + "override", + "package", + "param", + "private", + "property", + "protected", + "public", + "receiver", + "reified", + "return", + "sealed", + "set", + "setparam", + "super", + "suspend", + "tailrec", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "value", + "var", + "vararg", + "when", + "where", + "while", + "any", + "array", + "boolean", + "byte", + "char", + "charsequence", + "comparable", + "double", + "float", + "int", + "iterable", + "list", + "long", + "map", + "mutablelist", + "mutablemap", + "mutableset", + "nothing", + "number", + "pair", + "set", + "short", + "string", + "triple", + "unit", + // Common functions + "also", + "apply", + "assert", + "check", + "error", + "let", + "listof", + "mapof", + "mutablelistof", + "mutablemapof", + "mutablesetof", + "println", + "print", + "require", + "run", + "setof", + "takeif", + "takeunless", + "to", + "with", + // Collection and functional programming functions + "all", + "any", + "associate", + "associateby", + "associatewith", + "average", + "chunked", + "contains", + "count", + "distinct", + "distinctby", + "drop", + "droplast", + "dropwhile", + "elementat", + "elementatorelse", + "elementatornull", + "filter", + "filterindexed", + "filterisinstance", + "filternot", + "filternotnull", + "find", + "findlast", + "first", + "firstornull", + "flatmap", + "flatmapindexed", + "flatten", + "fold", + "foldindexed", + "foreach", + "foreachindexed", + "groupby", + "groupingby", + "indexof", + "indexoffirst", + "indexoflast", + "intersect", + "isempty", + "isnotempty", + "jointostring", + "last", + "lastindexof", + "lastornull", + "map", + "mapindexed", + "mapindexednotnull", + "mapnotnull", + "max", + "maxby", + "maxbyornull", + "maxof", + "maxofornull", + "maxofwith", + "maxofwithornull", + "maxornull", + "maxwith", + "maxwithornull", + "min", + "minby", + "minbyornull", + "minof", + "minofornull", + "minofwith", + "minofwithornull", + "minornull", + "minus", + "minwith", + "minwithornull", + "none", + "onEach", + "oneachindexed", + "partition", + "plus", + "reduce", + "reduceindexed", + "reduceindexedornull", + "reduceornull", + "reversed", + "scan", + "scanindexed", + "shuffle", + "shuffled", + "single", + "singleornull", + "slice", + "sort", + "sortby", + "sortbydescending", + "sortdescending", + "sorted", + "sortedby", + "sortedbydescending", + "sorteddescending", + "sortedwith", + "sum", + "sumby", + "sumof", + "take", + "takelast", + "takewhile", + "tolist", + "tomap", + "tomutablelist", + "tomutablemap", + "tomutableset", + "toset", + "tosortedmap", + "tosortedset", + "union", + "windowed", + "withindex", + "zip", + "zipwithnext", + ) + + val JAVA_KEYWORDS = + listOf( + "abstract", + "assert", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "default", + "do", + "double", + "else", + "enum", + "exports", + "extends", + "false", + "final", + "finally", + "float", + "for", + "goto", + "if", + "implements", + "import", + "instanceof", + "int", + "interface", + "long", + "module", + "native", + "new", + "null", + "package", + "permits", + "private", + "protected", + "provides", + "public", + "record", + "requires", + "return", + "sealed", + "short", + "static", + "strictfp", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "true", + "try", + "var", + "void", + "volatile", + "while", + "with", + "yield", + "boolean", + "byte", + "character", + "double", + "float", + "integer", + "long", + "short", + "void", + "arraylist", + "arrays", + "class", + "collections", + "comparable", + "enum", + "exception", + "hashmap", + "hashset", + "iterable", + "iterator", + "linkedlist", + "list", + "map", + "math", + "number", + "object", + "optional", + "override", + "runnable", + "runtimeexception", + "set", + "stream", + "string", + "stringbuilder", + "stringbuffer", + "system", + "thread", + "throwable", + ) + + val PYTHON_KEYWORDS = + listOf( + "and", + "as", + "assert", + "async", + "await", + "break", + "class", + "continue", + "def", + "del", + "elif", + "else", + "except", + "exec", + "finally", + "for", + "from", + "global", + "if", + "import", + "in", + "is", + "lambda", + "nonlocal", + "not", + "or", + "pass", + "print", + "raise", + "return", + "try", + "while", + "with", + "yield", + "false", + "none", + "true", + "bool", + "bytes", + "bytearray", + "complex", + "dict", + "float", + "frozenset", + "int", + "list", + "memoryview", + "object", + "range", + "set", + "slice", + "str", + "tuple", + "type", + "abs", + "all", + "any", + "bin", + "callable", + "chr", + "classmethod", + "compile", + "delattr", + "dir", + "divmod", + "enumerate", + "eval", + "filter", + "format", + "getattr", + "globals", + "hasattr", + "hash", + "help", + "hex", + "id", + "input", + "isinstance", + "issubclass", + "iter", + "len", + "locals", + "map", + "max", + "min", + "next", + "oct", + "open", + "ord", + "pow", + "property", + "repr", + "reversed", + "round", + "setattr", + "sorted", + "staticmethod", + "sum", + "super", + "vars", + "zip", + "__import__", + "__name__", + "__doc__", + "__file__", + "__init__", + "__main__", + "__dict__", + "__class__", + "__bases__", + "__self__", + ) + + val JAVASCRIPT_KEYWORDS = + listOf( + "abstract", + "arguments", + "async", + "await", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "double", + "else", + "enum", + "eval", + "export", + "extends", + "false", + "final", + "finally", + "float", + "for", + "from", + "function", + "goto", + "if", + "implements", + "import", + "in", + "instanceof", + "int", + "interface", + "let", + "long", + "native", + "new", + "null", + "of", + "package", + "private", + "protected", + "public", + "return", + "short", + "static", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "true", + "try", + "typeof", + "undefined", + "var", + "void", + "volatile", + "while", + "with", + "yield", + "array", + "arraybuffer", + "bigint", + "bigint64array", + "biguint64array", + "boolean", + "dataview", + "date", + "error", + "evalerror", + "float32array", + "float64array", + "function", + "infinity", + "int8array", + "int16array", + "int32array", + "intl", + "json", + "map", + "math", + "nan", + "number", + "object", + "promise", + "proxy", + "rangeerror", + "referenceerror", + "reflect", + "regexp", + "set", + "string", + "symbol", + "syntaxerror", + "typeerror", + "uint8array", + "uint8clampedarray", + "uint16array", + "uint32array", + "urierror", + "weakmap", + "weakset", + // Global functions + "alert", + "clearinterval", + "cleartimeout", + "console", + "decodeuri", + "decodeuricomponent", + "encodeuri", + "encodeuricomponent", + "escape", + "isfinite", + "isnan", + "parsefloat", + "parseint", + "setinterval", + "settimeout", + "unescape", + // Common browser/Node globals + "document", + "global", + "globalthis", + "process", + "require", + "window", + "all", + "concat", + "entries", + "every", + "fill", + "filter", + "find", + "findindex", + "findlast", + "findlastindex", + "flat", + "flatmap", + "foreach", + "from", + "includes", + "indexof", + "isarray", + "join", + "keys", + "lastindexof", + "length", + "map", + "of", + "pop", + "push", + "reduce", + "reduceright", + "reverse", + "shift", + "slice", + "some", + "sort", + "splice", + "tolocalestring", + "tostring", + "unshift", + "values", + // String methods + "charat", + "charcodeat", + "codepointat", + "concat", + "endswith", + "includes", + "indexof", + "lastindexof", + "localecompare", + "match", + "matchall", + "normalize", + "padend", + "padstart", + "repeat", + "replace", + "replaceall", + "search", + "slice", + "split", + "startswith", + "substring", + "tolocalelowercase", + "tolocaleuppercase", + "tolowercase", + "touppercase", + "trim", + "trimend", + "trimstart", + "valueof", + // Object methods + "assign", + "create", + "defineproperties", + "defineproperty", + "entries", + "freeze", + "fromentries", + "getownpropertydescriptor", + "getownpropertydescriptors", + "getownpropertynames", + "getownpropertysymbols", + "getprototypeof", + "hasown", + "hasownproperty", + "is", + "isextensible", + "isfrozen", + "issealed", + "keys", + "preventextensions", + "seal", + "setprototypeof", + "values", + ) + + val TYPESCRIPT_KEYWORDS = + listOf( + "abstract", + "any", + "as", + "asserts", + "async", + "await", + "bigint", + "boolean", + "break", + "case", + "catch", + "class", + "const", + "constructor", + "continue", + "debugger", + "declare", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "from", + "function", + "get", + "if", + "implements", + "import", + "in", + "infer", + "instanceof", + "interface", + "is", + "keyof", + "let", + "module", + "namespace", + "never", + "new", + "null", + "number", + "object", + "of", + "package", + "private", + "protected", + "public", + "readonly", + "require", + "return", + "satisfies", + "set", + "static", + "string", + "super", + "switch", + "symbol", + "this", + "throw", + "true", + "try", + "type", + "typeof", + "undefined", + "unique", + "unknown", + "var", + "void", + "while", + "with", + "yield", + // Utility types + "awaited", + "capitalize", + "constructorparameters", + "exclude", + "extract", + "instancetype", + "lowercase", + "nonnullable", + "omit", + "parameters", + "partial", + "pick", + "readonly", + "record", + "required", + "returntype", + "uncapitalize", + "uppercase", + // Built-in objects (inherited from JS) + "array", + "arraybuffer", + "bigint", + "bigint64array", + "biguint64array", + "boolean", + "dataview", + "date", + "error", + "evalerror", + "float32array", + "float64array", + "function", + "infinity", + "int8array", + "int16array", + "int32array", + "intl", + "json", + "map", + "math", + "nan", + "number", + "object", + "promise", + "proxy", + "rangeerror", + "referenceerror", + "reflect", + "regexp", + "set", + "string", + "symbol", + "syntaxerror", + "typeerror", + "uint8array", + "uint8clampedarray", + "uint16array", + "uint32array", + "urierror", + "weakmap", + "weakset", + // Global functions + "alert", + "clearInterval", + "clearTimeout", + "console", + "decodeuri", + "decodeuricomponent", + "encodeuri", + "encodeuricomponent", + "escape", + "isfinite", + "isnan", + "parsefloat", + "parseint", + "setinterval", + "settimeout", + "unescape", + // Common browser/Node globals + "document", + "global", + "globalthis", + "process", + "window", + // Array and collection methods (same as JavaScript) + "all", + "concat", + "entries", + "every", + "fill", + "filter", + "find", + "findindex", + "findlast", + "findlastindex", + "flat", + "flatmap", + "foreach", + "from", + "includes", + "indexof", + "isarray", + "join", + "keys", + "lastindexof", + "length", + "map", + "of", + "pop", + "push", + "reduce", + "reduceright", + "reverse", + "shift", + "slice", + "some", + "sort", + "splice", + "tolocalestring", + "tostring", + "unshift", + "values", + ) + + val CPP_KEYWORDS = + listOf( + "alignas", + "alignof", + "and", + "and_eq", + "asm", + "auto", + "bitand", + "bitor", + "bool", + "break", + "case", + "catch", + "char", + "char8_t", + "char16_t", + "char32_t", + "class", + "compl", + "concept", + "const", + "consteval", + "constexpr", + "constinit", + "const_cast", + "continue", + "co_await", + "co_return", + "co_yield", + "decltype", + "default", + "delete", + "do", + "double", + "dynamic_cast", + "else", + "enum", + "explicit", + "export", + "extern", + "false", + "float", + "for", + "friend", + "goto", + "if", + "inline", + "int", + "long", + "mutable", + "namespace", + "new", + "noexcept", + "not", + "not_eq", + "nullptr", + "operator", + "or", + "or_eq", + "private", + "protected", + "public", + "register", + "reinterpret_cast", + "requires", + "return", + "short", + "signed", + "sizeof", + "static", + "static_assert", + "static_cast", + "struct", + "switch", + "template", + "this", + "thread_local", + "throw", + "true", + "try", + "typedef", + "typeid", + "typename", + "union", + "unsigned", + "using", + "virtual", + "void", + "volatile", + "wchar_t", + "while", + "xor", + "xor_eq", + // Common STL types + "array", + "bitset", + "deque", + "forward_list", + "list", + "map", + "multimap", + "multiset", + "pair", + "queue", + "set", + "stack", + "string", + "tuple", + "unordered_map", + "unordered_multimap", + "unordered_multiset", + "unordered_set", + "vector", + // Common STL utilities + "cout", + "cin", + "cerr", + "endl", + "make_pair", + "make_tuple", + "make_unique", + "make_shared", + "move", + "forward", + "swap", + "size_t", + "std", + "unique_ptr", + "shared_ptr", + "weak_ptr", + "nullptr_t", + "optional", + "variant", + "any", + "string_view", + // STL algorithms and functional + "accumulate", + "adjacent_find", + "all_of", + "any_of", + "binary_search", + "copy", + "copy_if", + "count", + "count_if", + "equal", + "fill", + "fill_n", + "find", + "find_if", + "find_if_not", + "for_each", + "generate", + "includes", + "lower_bound", + "max_element", + "merge", + "min_element", + "mismatch", + "none_of", + "nth_element", + "partial_sort", + "partition", + "remove", + "remove_if", + "replace", + "replace_if", + "reverse", + "rotate", + "search", + "sort", + "stable_sort", + "transform", + "unique", + "upper_bound", + // Common member functions + "begin", + "end", + "rbegin", + "rend", + "cbegin", + "cend", + "size", + "empty", + "clear", + "insert", + "erase", + "push_back", + "pop_back", + "push_front", + "pop_front", + "front", + "back", + "at", + "data", + "emplace", + "emplace_back", + "emplace_front", + ) + + val GO_KEYWORDS = + listOf( + "break", + "case", + "chan", + "const", + "continue", + "default", + "defer", + "else", + "fallthrough", + "false", + "for", + "func", + "go", + "goto", + "if", + "import", + "interface", + "map", + "nil", + "package", + "range", + "return", + "select", + "struct", + "switch", + "true", + "type", + "var", + // Built-in types + "bool", + "byte", + "complex64", + "complex128", + "error", + "float32", + "float64", + "int", + "int8", + "int16", + "int32", + "int64", + "rune", + "string", + "uint", + "uint8", + "uint16", + "uint32", + "uint64", + "uintptr", + // Built-in functions + "append", + "cap", + "close", + "complex", + "copy", + "delete", + "imag", + "len", + "make", + "new", + "panic", + "print", + "println", + "real", + "recover", + // Additional slice and map operations + "clear", + "contains", + "copy", + "delete", + "equal", + "index", + "max", + "min", + "reverse", + "sort", + // Common functions from packages + "all", + "any", + "clone", + "compare", + "concat", + "count", + "cut", + "fields", + "filter", + "find", + "fold", + "foreach", + "hasprefix", + "hassuffix", + "indexof", + "join", + "lastindex", + "map", + "reduce", + "repeat", + "replace", + "search", + "slice", + "split", + "trim", + "trimleft", + "trimright", + "trimspace", + ) + + val RUST_KEYWORDS = + listOf( + "as", + "async", + "await", + "break", + "const", + "continue", + "crate", + "dyn", + "else", + "enum", + "extern", + "false", + "fn", + "for", + "if", + "impl", + "in", + "let", + "loop", + "match", + "mod", + "move", + "mut", + "pub", + "ref", + "return", + "self", + "self_type", + "static", + "struct", + "super", + "trait", + "true", + "type", + "union", + "unsafe", + "use", + "where", + "while", + // Built-in types + "bool", + "char", + "f32", + "f64", + "i8", + "i16", + "i32", + "i64", + "i128", + "isize", + "str", + "u8", + "u16", + "u32", + "u64", + "u128", + "usize", + // Common types and traits + "box", + "clone", + "copy", + "debug", + "default", + "drop", + "eq", + "err", + "hashmap", + "hashset", + "none", + "ok", + "option", + "ord", + "partialeq", + "partialord", + "rc", + "refcell", + "result", + "send", + "some", + "string", + "sync", + "vec", + // Common macros + "assert", + "assert_eq", + "assert_ne", + "dbg", + "eprintln", + "format", + "panic", + "print", + "println", + "todo", + "unimplemented", + "unreachable", + "vec", + // Iterator and collection methods + "all", + "any", + "chain", + "cloned", + "collect", + "copied", + "count", + "cycle", + "enumerate", + "filter", + "filter_map", + "find", + "find_map", + "flat_map", + "flatten", + "fold", + "for_each", + "inspect", + "last", + "map", + "max", + "max_by", + "max_by_key", + "min", + "min_by", + "min_by_key", + "next", + "nth", + "partition", + "peekable", + "position", + "product", + "reduce", + "rev", + "scan", + "skip", + "skip_while", + "step_by", + "sum", + "take", + "take_while", + "try_fold", + "unzip", + "zip", + // Common methods + "append", + "as_mut", + "as_ref", + "as_slice", + "capacity", + "clear", + "contains", + "drain", + "extend", + "get", + "get_mut", + "insert", + "is_empty", + "iter", + "iter_mut", + "len", + "pop", + "push", + "remove", + "reserve", + "resize", + "retain", + "reverse", + "sort", + "sort_by", + "sort_by_key", + "split", + "split_at", + "split_off", + "swap", + "truncate", + "with_capacity", + ) + + val RUBY_KEYWORDS = + listOf( + "begin", + "end", + "__encoding__", + "__file__", + "__line__", + "alias", + "and", + "begin", + "break", + "case", + "class", + "def", + "defined?", + "do", + "else", + "elsif", + "end", + "ensure", + "false", + "for", + "if", + "in", + "module", + "next", + "nil", + "not", + "or", + "redo", + "rescue", + "retry", + "return", + "self", + "super", + "then", + "true", + "undef", + "unless", + "until", + "when", + "while", + "yield", + // Common classes and modules + "array", + "basicobject", + "bignum", + "class", + "dir", + "encoding", + "enumerable", + "enumerator", + "falseclass", + "file", + "fixnum", + "float", + "hash", + "integer", + "io", + "kernel", + "math", + "module", + "nilclass", + "numeric", + "object", + "proc", + "range", + "rational", + "regexp", + "string", + "symbol", + "thread", + "time", + "trueclass", + // Common methods + "attr_accessor", + "attr_reader", + "attr_writer", + "extend", + "include", + "lambda", + "load", + "loop", + "new", + "p", + "print", + "printf", + "private", + "protected", + "public", + "puts", + "raise", + "rand", + "require", + "require_relative", + "sleep", + // Enumerable methods + "all", + "any", + "chunk", + "chunk_while", + "collect", + "compact", + "count", + "cycle", + "detect", + "drop", + "drop_while", + "each", + "each_cons", + "each_slice", + "each_with_index", + "each_with_object", + "entries", + "filter", + "filter_map", + "find", + "find_all", + "find_index", + "first", + "flat_map", + "grep", + "grep_v", + "group_by", + "include", + "inject", + "last", + "lazy", + "map", + "max", + "max_by", + "member", + "min", + "min_by", + "minmax", + "minmax_by", + "none", + "one", + "partition", + "reduce", + "reject", + "reverse_each", + "select", + "slice_after", + "slice_before", + "slice_when", + "sort", + "sort_by", + "sum", + "take", + "take_while", + "tally", + "to_a", + "to_h", + "uniq", + "zip", + // Array/String methods + "append", + "clear", + "concat", + "delete", + "delete_at", + "delete_if", + "dig", + "drop", + "dup", + "empty", + "fetch", + "fill", + "flatten", + "index", + "insert", + "join", + "keep_if", + "length", + "pop", + "prepend", + "push", + "replace", + "reverse", + "rotate", + "sample", + "shift", + "shuffle", + "size", + "slice", + "sort", + "transpose", + "unshift", + "values_at", + ) + + val CSHARP_KEYWORDS = + listOf( + "abstract", + "add", + "alias", + "as", + "ascending", + "async", + "await", + "base", + "bool", + "break", + "by", + "byte", + "case", + "catch", + "char", + "checked", + "class", + "const", + "continue", + "decimal", + "default", + "delegate", + "descending", + "do", + "double", + "dynamic", + "else", + "enum", + "equals", + "event", + "explicit", + "extern", + "false", + "finally", + "fixed", + "float", + "for", + "foreach", + "from", + "get", + "global", + "goto", + "group", + "if", + "implicit", + "in", + "init", + "int", + "interface", + "internal", + "into", + "is", + "join", + "let", + "lock", + "long", + "managed", + "nameof", + "namespace", + "new", + "nint", + "not", + "notnull", + "nuint", + "null", + "object", + "on", + "operator", + "or", + "orderby", + "out", + "override", + "params", + "partial", + "private", + "protected", + "public", + "readonly", + "record", + "ref", + "remove", + "required", + "return", + "sbyte", + "sealed", + "select", + "set", + "short", + "sizeof", + "stackalloc", + "static", + "string", + "struct", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "uint", + "ulong", + "unchecked", + "unmanaged", + "unsafe", + "ushort", + "using", + "value", + "var", + "virtual", + "void", + "volatile", + "when", + "where", + "while", + "with", + "yield", + "action", + "array", + "boolean", + "byte", + "char", + "console", + "datetime", + "decimal", + "dictionary", + "double", + "enum", + "exception", + "func", + "guid", + "hashset", + "idisposable", + "ienumerable", + "ilist", + "int16", + "int32", + "int64", + "list", + "math", + "object", + "queue", + "random", + "sbyte", + "single", + "stack", + "string", + "stringbuilder", + "system", + "task", + "timespan", + "tuple", + "type", + "uint16", + "uint32", + "uint64", + "uri", + "void", + // LINQ and collection methods + "aggregate", + "all", + "any", + "append", + "average", + "cast", + "concat", + "contains", + "count", + "defaultifempty", + "distinct", + "elementat", + "elementatordefault", + "empty", + "except", + "first", + "firstordefault", + "groupby", + "groupjoin", + "intersect", + "join", + "last", + "lastordefault", + "max", + "min", + "oftype", + "orderby", + "orderbydescending", + "prepend", + "range", + "repeat", + "reverse", + "select", + "selectmany", + "sequenceequal", + "single", + "singleordefault", + "skip", + "skiplast", + "skipwhile", + "sum", + "take", + "takelast", + "takewhile", + "thenby", + "thenbydescending", + "toarray", + "todictionary", + "tolist", + "tolookup", + "union", + "where", + "zip", + // Common collection methods + "add", + "addrange", + "clear", + "contains", + "copyto", + "exists", + "find", + "findall", + "findindex", + "findlast", + "findlastindex", + "foreach", + "getrange", + "indexof", + "insert", + "insertrange", + "lastindexof", + "remove", + "removeall", + "removeat", + "removerange", + "sort", + "toarray", + "trimexcess", + "trueforall", + ) + + val PHP_KEYWORDS = + listOf( + // Keywords + "__halt_compiler", + "abstract", + "and", + "array", + "as", + "break", + "callable", + "case", + "catch", + "class", + "clone", + "const", + "continue", + "declare", + "default", + "die", + "do", + "echo", + "else", + "elseif", + "empty", + "enddeclare", + "endfor", + "endforeach", + "endif", + "endswitch", + "endwhile", + "enum", + "eval", + "exit", + "extends", + "false", + "final", + "finally", + "fn", + "for", + "foreach", + "function", + "global", + "goto", + "if", + "implements", + "include", + "include_once", + "instanceof", + "insteadof", + "interface", + "isset", + "list", + "match", + "namespace", + "new", + "null", + "or", + "print", + "private", + "protected", + "public", + "readonly", + "require", + "require_once", + "return", + "static", + "switch", + "throw", + "trait", + "true", + "try", + "unset", + "use", + "var", + "while", + "xor", + "yield", + "yield from", + // Type declarations + "bool", + "float", + "int", + "string", + "array", + "object", + "callable", + "iterable", + "mixed", + "never", + "void", + "resource", + // Magic constants + "__class__", + "__dir__", + "__file__", + "__function__", + "__line__", + "__method__", + "__namespace__", + "__trait__", + // Magic methods + "__construct", + "__destruct", + "__call", + "__callstatic", + "__get", + "__set", + "__isset", + "__unset", + "__sleep", + "__wakeup", + "__tostring", + "__invoke", + "__set_state", + "__clone", + "__debuginfo", + "__serialize", + "__unserialize", + // Common functions + "count", + "sizeof", + "strlen", + "strpos", + "substr", + "str_replace", + "explode", + "implode", + "trim", + "ltrim", + "rtrim", + "strtolower", + "strtoupper", + "ucfirst", + "ucwords", + "htmlspecialchars", + "htmlentities", + "strip_tags", + "addslashes", + "stripslashes", + "is_array", + "is_bool", + "is_float", + "is_int", + "is_null", + "is_numeric", + "is_object", + "is_string", + "in_array", + "array_key_exists", + "array_keys", + "array_values", + "array_merge", + "array_push", + "array_pop", + "array_shift", + "array_unshift", + "array_slice", + "array_splice", + "array_map", + "array_filter", + "array_reduce", + "json_encode", + "json_decode", + // Array functions + "array_chunk", + "array_column", + "array_combine", + "array_count_values", + "array_diff", + "array_diff_assoc", + "array_diff_key", + "array_fill", + "array_fill_keys", + "array_flip", + "array_intersect", + "array_intersect_assoc", + "array_intersect_key", + "array_key_first", + "array_key_last", + "array_map", + "array_merge_recursive", + "array_multisort", + "array_pad", + "array_product", + "array_rand", + "array_reduce", + "array_replace", + "array_reverse", + "array_search", + "array_slice", + "array_sum", + "array_unique", + "array_walk", + "array_walk_recursive", + "arsort", + "asort", + "compact", + "current", + "each", + "end", + "extract", + "key", + "krsort", + "ksort", + "list", + "natcasesort", + "natsort", + "next", + "pos", + "prev", + "range", + "reset", + "rsort", + "shuffle", + "sort", + "uasort", + "uksort", + "usort", + "file_get_contents", + "file_put_contents", + "fopen", + "fclose", + "fread", + "fwrite", + "file_exists", + "is_file", + "is_dir", + "mkdir", + "rmdir", + "unlink", + "date", + "time", + "strtotime", + "mktime", + "header", + "session_start", + "session_destroy", + "setcookie", + "define", + "defined", + "constant", + "class_exists", + "method_exists", + "property_exists", + "function_exists", + "get_class", + "get_parent_class", + "is_subclass_of", + "call_user_func", + "call_user_func_array", + "func_get_args", + "func_num_args", + "var_dump", + "print_r", + "error_reporting", + "ini_set", + "ini_get", + "phpinfo", + "phpversion", + "extension_loaded", + ) + + // Map languages to their keywords + val LANGUAGE_KEYWORDS = + mapOf( + "kotlin" to KOTLIN_KEYWORDS, + "java" to JAVA_KEYWORDS, + "python" to PYTHON_KEYWORDS, + "ruby" to RUBY_KEYWORDS, + "javascript" to JAVASCRIPT_KEYWORDS, + "typescript" to TYPESCRIPT_KEYWORDS, + "cpp" to CPP_KEYWORDS, + "c" to CPP_KEYWORDS, // C and C++ share most keywords + "go" to GO_KEYWORDS, + "rust" to RUST_KEYWORDS, + "csharp" to CSHARP_KEYWORDS, + "php" to PHP_KEYWORDS, + ) + + // File path patterns that typically indicate external/excluded files + val EXTERNAL_FILE_PATTERNS = + listOf( + "/build/", + "/target/", + "/.gradle/", + "/node_modules/", + "/.git/", + "/.idea/", + "/out/", + "/dist/", + "/bin/", + "/lib/", + "/libs/", + "/vendor/", + "/.vscode/", + "/temp/", + "/tmp/", + "/.cache/", + "/cache/", + "/logs/", + "/.m2/", + "/.npm/", + "/.yarn/", + "/venv/", + "/.venv/", + "/env/", + "/.env/", + "__pycache__/", + "/.pytest_cache/", + "/coverage/", + ".min.js", + ".min.css", + ".jar", + ".war", + ".ear", + ".class", + ) +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/PluginConflictUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/PluginConflictUtils.kt new file mode 100644 index 0000000..5c23add --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/PluginConflictUtils.kt @@ -0,0 +1,143 @@ +package com.oxidecode.utils + +import com.intellij.ide.actions.ShowSettingsUtilImpl +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ex.ConfigurableWrapper +import com.intellij.openapi.project.Project +import com.intellij.platform.ide.progress.ModalTaskOwner +import com.intellij.platform.ide.progress.runWithModalProgressBlocking +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.util.Locale.getDefault + +private val logger = Logger.getInstance("PluginConflictUtils") + +/** + * Disables IntelliJ's Full Line completion by unchecking all inline completion checkboxes. + * This effectively disables autocomplete for conflicting plugins. + * + * @param project The current project + * @return True if Full Line completion was successfully disabled, false otherwise + */ +@RequiresEdt +fun disableFullLineCompletion(project: Project): Boolean = + try { + // getConfigurables can trigger configurable initialization that performs blocking I/O + // (e.g., Python package manager checks), so we need to run it with modal progress + val allConfigurables: List = + runWithModalProgressBlocking(ModalTaskOwner.project(project), "Loading settings...") { + ShowSettingsUtilImpl.getConfigurables( + project = project, + withIdeSettings = true, + checkNonDefaultProject = false, + ) + } + + val inlineCompletionConfigurable = + allConfigurables.find { configurable -> + // Use getDisplayNameFast() to avoid loading the configurable class. + // Accessing displayName directly triggers class loading for ALL configurables, + // which causes "No display name specified" errors for third-party plugins + // (like Indent Rainbow, Rainbow Brackets, PHP Inspections) that don't specify displayName in XML. + // We only use displayNameFast for ConfigurableWrapper, and skip configurables + // that don't have a fast display name to avoid triggering class loading. + try { + val name = (configurable as? ConfigurableWrapper)?.displayNameFast + name?.lowercase(getDefault()) == "inline completion" + } catch (e: Exception) { + // Some plugins may throw exceptions even when accessing displayNameFast + false + } + } + + if (inlineCompletionConfigurable != null) { + val extensionPoint = (inlineCompletionConfigurable as ConfigurableWrapper).extensionPoint + val configurableComp = extensionPoint.createConfigurable() + val configurableComponent = configurableComp?.createComponent() + + // Traverse the component tree to find and uncheck all JCheckBox components + val uncheckedCount = uncheckAllCheckboxes(configurableComponent) + + // Apply the changes to persist them + if (uncheckedCount > 0) { + configurableComp?.apply() + } + + // Dispose the configurable to clean up + configurableComp?.disposeUIResources() + + uncheckedCount > 0 + } else { + false + } + } catch (e: Exception) { + logger.warn("Failed to disable Full Line completion", e) + false + } + +/** + * Disables Full Line completion and shows a success notification with the list of conflicting plugins. + * + * @param project The current project + */ +fun disableFullLineCompletionAndNotify(project: Project) { + // Get conflicting plugins to show in notification + val conflictingPlugins = + OxideCodeConstants.PLUGINS_TO_DISABLE + .filter { PluginManagerCore.isPluginInstalled(it) && PluginManagerCore.getPlugin(it)?.isEnabled == true } + + if (conflictingPlugins.isEmpty()) { + return + } + + val pluginNames = + conflictingPlugins + .map { pluginId -> + OxideCodeConstants.PLUGIN_ID_TO_NAME[pluginId] ?: PluginManagerCore.getPlugin(pluginId)?.name ?: pluginId.idString + }.joinToString(separator = ", ") + + // Disable Full Line completion + // val success = disableFullLineCompletion(project) + + + // Show success notification + showNotification( + project = project, + title = "Conflicting Autocomplete Plugins", + body = + "The following plugins have conflicting autocomplete suggestions: $pluginNames. " + + "If you still see conflicting autocomplete suggestions, please disable these plugins manually in Settings > Plugins.", + notificationGroup = "Conflicting Autocomplete Plugins", + ) + +} + +/** + * Recursively traverses a Swing component tree and unchecks all JCheckBox components. + * This is used to programmatically disable the "Enable local Full Line completion suggestions" + * checkboxes by directly manipulating the UI components. + * + * @param component The root component to start traversing from + * @return The number of checkboxes that were unchecked + */ +private fun uncheckAllCheckboxes(component: java.awt.Component?): Int { + if (component == null) return 0 + + var count = 0 + + // If this is a JCheckBox and it's selected, uncheck it + if (component is javax.swing.JCheckBox && component.isSelected) { + component.isSelected = false + count++ + } + + // If this is a container, recursively process all children + if (component is java.awt.Container) { + for (child in component.components) { + count += uncheckAllCheckboxes(child) + } + } + + return count +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/ReflectionUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/ReflectionUtils.kt new file mode 100644 index 0000000..01987ad --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/ReflectionUtils.kt @@ -0,0 +1,40 @@ +package com.oxidecode.utils + +fun tryLoadClass(name: String) = runCatching { Class.forName(name) }.getOrNull() + +fun tryMethod( + clazz: Class<*>?, + methodName: String, +) = runCatching { clazz?.getMethod(methodName) }.getOrNull() + +fun tryMethodWithParams( + clazz: Class<*>?, + methodName: String, + vararg paramTypes: Class<*>?, +) = runCatching { + val nonNullParams = paramTypes.filterNotNull().toTypedArray() + clazz?.getMethod(methodName, *nonNullParams) +}.getOrNull() + +fun tryInvokeMethod( + instance: Any?, + method: java.lang.reflect.Method?, + vararg args: Any?, +) = runCatching { method?.invoke(instance, *args) }.getOrNull() + +fun invokeMethod( + instance: Any?, + method: java.lang.reflect.Method?, + vararg args: Any?, +): Any? = method?.invoke(instance, *args) + +fun tryInvokeStaticMethod( + method: java.lang.reflect.Method?, + vararg args: Any?, +) = runCatching { method?.invoke(null, *args) }.getOrNull() + +fun tryGetStaticMethod( + clazz: Class<*>?, + methodName: String, + vararg paramTypes: Class<*>, +) = runCatching { clazz?.getMethod(methodName, *paramTypes) }.getOrNull() diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/RequestUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/RequestUtils.kt new file mode 100644 index 0000000..8d86b99 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/RequestUtils.kt @@ -0,0 +1,186 @@ +package com.oxidecode.utils + +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.http.HttpResponse + +val defaultJson = + Json { + encodeDefaults = true + ignoreUnknownKeys = true + } + +fun encodeString( + request: T, + serializer: SerializationStrategy, +) = defaultJson.encodeToString( + serializer, + request, +) + +fun getJSONPrefix(buffer: String): Pair, Int> { + if (buffer.startsWith("null")) { + // for heartbeat messages + return Pair(emptyList(), "null".length) + } + + val stack = mutableListOf() + var currentIndex = 0 + val results = mutableListOf() + var inString = false + var escapeNext = false + + for (i in buffer.indices) { + val char = buffer[i] + + if (escapeNext) { + escapeNext = false + continue + } + + if (char == '\\') { + escapeNext = true + continue + } + + if (char == '"') { + inString = !inString + } + + if (!inString) { + if (char == '[' || char == '{' || char == '(') { + stack.add(char) + } else if (stack.lastOrNull()?.let { getMatchingBracket(it) } == char) { + stack.removeAt(stack.lastIndex) + if (stack.isEmpty()) { + try { + val jsonElement = Json.parseToJsonElement(buffer.substring(currentIndex, i + 1)) + results.add(jsonElement) + currentIndex = i + 1 + } catch (e: Exception) { + continue + } + } + } + } + } + + // if (currentIndex == 0) { + // println(buffer) // TODO: optimize later + // } + + return Pair(results, currentIndex) +} + +private fun getMatchingBracket(char: Char): Char? = + when (char) { + '[' -> ']' + '{' -> '}' + '(' -> ')' + else -> null + } + +inline fun HttpURLConnection.sendRequest( + request: T, + serializer: SerializationStrategy, +) = apply { + val postData = encodeString(request, serializer) + + outputStream.use { os -> + os.write(postData.toByteArray()) + os.flush() + } +} + +inline fun HttpURLConnection.streamJson() = + flow { + var currentText = "" + + try { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + val buffer = CharArray(1024) + var bytesRead: Int + while (reader.read(buffer).also { bytesRead = it } != -1) { + currentText += String(buffer, 0, bytesRead) + val (jsonElements, currentIndex) = getJSONPrefix(currentText) + currentText = currentText.drop(currentIndex) + + for (jsonElement in jsonElements) { + try { + val output = defaultJson.decodeFromString(jsonElement.toString()) + emit(output) + } catch (e: Exception) { + println("Error decoding JSON ${e.message}") + continue + } + } + } + } + } catch (e: java.io.IOException) { + // Handle stream closure gracefully - this can happen when: + // 1. Server closes the connection (RST_STREAM) + // 2. Network timeout occurs + // 3. Request is cancelled + // If we've already emitted some data, this is not necessarily an error + if (e.message?.contains("closed") == true || e.message?.contains("RST_STREAM") == true) { + // Stream was closed, but we may have already received valid data + // Just exit gracefully + } else { + // Re-throw other IOExceptions + throw e + } + } + } + +inline fun HttpResponse.streamJson() = + flow { + var currentText = "" + + try { + BufferedReader(InputStreamReader(body())).use { reader -> + val buffer = CharArray(1024) + var bytesRead: Int + while (reader.read(buffer).also { bytesRead = it } != -1) { + currentText += String(buffer, 0, bytesRead) + val (jsonElements, currentIndex) = getJSONPrefix(currentText) + currentText = currentText.drop(currentIndex) + + for (jsonElement in jsonElements) { + try { + val output = defaultJson.decodeFromString(jsonElement.toString()) + emit(output) + } catch (e: Exception) { + println("Error decoding JSON ${e.message}") + continue + } + } + } + } + } catch (e: java.io.IOException) { + // Handle stream closure gracefully - this can happen when: + // 1. Server closes the connection (RST_STREAM) + // 2. Network timeout occurs + // 3. Request is cancelled + // If we've already emitted some data, this is not necessarily an error + if (e.message?.contains("closed") == true || e.message?.contains("RST_STREAM") == true) { + // Stream was closed, but we may have already received valid data + // Just exit gracefully + } else { + // Re-throw other IOExceptions + throw e + } + } + } + +fun HttpResponse.raiseForStatus(): HttpResponse { + if (statusCode() !in 200..399) { + throw java.io.IOException("HTTP ${statusCode()}") + } + return this +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/StringDistance.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/StringDistance.kt new file mode 100644 index 0000000..4cad883 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/StringDistance.kt @@ -0,0 +1,129 @@ +package com.oxidecode.utils + +import kotlin.math.min + +/** + * Utility functions for calculating string distances and similarities. + */ +object StringDistance { + /** + * Calculates the Levenshtein distance between two strings. + * The Levenshtein distance is the minimum number of single-character edits + * (insertions, deletions, or substitutions) required to change one string into another. + * + * @param s1 First string + * @param s2 Second string + * @return The Levenshtein distance between the two strings + */ + fun levenshteinDistance( + s1: String, + s2: String, + ): Int { + val len1 = s1.length + val len2 = s2.length + + // Guard clause for large inputs to prevent performance issues + if (len1.toLong() * len2.toLong() > 100_000) { + return maxOf(len1, len2) + } + + // Create a matrix to store distances + val dp = Array(len1 + 1) { IntArray(len2 + 1) } + + // Initialize base cases + for (i in 0..len1) { + dp[i][0] = i + } + for (j in 0..len2) { + dp[0][j] = j + } + + // Fill the matrix + for (i in 1..len1) { + for (j in 1..len2) { + val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1 + dp[i][j] = + min( + min( + dp[i - 1][j] + 1, // deletion + dp[i][j - 1] + 1, // insertion + ), + dp[i - 1][j - 1] + cost, // substitution + ) + } + } + + return dp[len1][len2] + } + + /** + * Calculates a normalized similarity score between two strings based on Levenshtein distance. + * Returns a value between 0.0 (completely different) and 1.0 (identical). + * + * @param s1 First string + * @param s2 Second string + * @return Similarity score between 0.0 and 1.0 + */ + fun levenshteinSimilarity( + s1: String, + s2: String, + ): Double { + val maxLength = maxOf(s1.length, s2.length) + if (maxLength == 0) return 1.0 + + val distance = levenshteinDistance(s1, s2) + return 1.0 - (distance.toDouble() / maxLength) + } + + /** + * Calculates the length of the Longest Common Subsequence (LCS) between two strings. + * A subsequence is a sequence that can be derived from another sequence by deleting + * some or no elements without changing the order of the remaining elements. + * + * @param s1 First string + * @param s2 Second string + * @return The length of the LCS + */ + fun lcsLength( + s1: String, + s2: String, + ): Int { + val len1 = s1.length + val len2 = s2.length + + // Create a matrix to store LCS lengths + val dp = Array(len1 + 1) { IntArray(len2 + 1) } + + // Fill the matrix + for (i in 1..len1) { + for (j in 1..len2) { + if (s1[i - 1] == s2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1 + } else { + dp[i][j] = maxOf(dp[i - 1][j], dp[i][j - 1]) + } + } + } + + return dp[len1][len2] + } + + /** + * Calculates a normalized similarity score between two strings based on LCS. + * Returns a value between 0.0 (no common subsequence) and 1.0 (identical). + * + * @param s1 First string + * @param s2 Second string + * @return Similarity score between 0.0 and 1.0 + */ + fun lcsSimilarity( + s1: String, + s2: String, + ): Double { + val maxLength = maxOf(s1.length, s2.length) + if (maxLength == 0) return 1.0 + + val lcsLen = lcsLength(s1, s2) + return lcsLen.toDouble() / maxLength + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/StringUtils.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/StringUtils.kt new file mode 100644 index 0000000..5d7494a --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/StringUtils.kt @@ -0,0 +1,697 @@ +package com.oxidecode.utils + +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import java.awt.FontMetrics +import java.awt.event.KeyEvent +import java.io.File +import java.security.MessageDigest + +fun isPrintableChar(e: KeyEvent): Boolean { + val c = e.keyChar + if (Character.isISOControl(c)) return false + if (c == KeyEvent.CHAR_UNDEFINED) return false + if (!Character.isDefined(c)) return false + return true +} + +fun findLongestCommonSubstring( + str1: String, + str2: String?, +): Pair { + if (str2 == null) return Pair(-1, 0) + + val s1 = str1.lowercase() + val s2 = str2.lowercase() + var maxLength = 0 + var startIndex = 0 + + val dp = Array(s1.length + 1) { IntArray(s2.length + 1) } + + for (i in 1..s1.length) { + for (j in 1..s2.length) { + if (s1[i - 1] == s2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1 + if (dp[i][j] > maxLength) { + maxLength = dp[i][j] + startIndex = i - maxLength + } + } + } + } + + return Pair(startIndex, maxLength) +} + +/** + * Calculates a modified edit distance where arbitrary length deletions count as distance 1. + * This helps match queries with "jumps" like "sweepmessageaction" → "sweepcommitmessageaction". + * + * @param query The search query + * @param target The target string to match against + * @return The modified edit distance (lower is better) + */ +fun calculateJumpDistance( + query: String, + target: String, +): Int { + if (query.isEmpty()) return 0 + if (target.isEmpty()) return query.length + + val dp = Array(query.length + 1) { IntArray(target.length + 1) } + + // Initialize first row and column + for (i in 0..query.length) dp[i][0] = i + for (j in 0..target.length) dp[0][j] = 1 // Any deletion from target costs 1 + dp[0][0] = 0 + + for (i in 1..query.length) { + for (j in 1..target.length) { + if (query[i - 1] == target[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] // Match + } else { + dp[i][j] = + minOf( + dp[i - 1][j] + 1, // Insert into query + dp[i][j - 1] + 1, // Delete from target (jump) + dp[i - 1][j - 1] + 1, // Substitute + ) + } + } + } + + return dp[query.length][target.length] +} + +/** + * Calculates the sum of contiguous character matches between query and target string + * that have length at least 2. + * + * @param query The search query + * @param target The target string to match against + * @param totalMatches Maximum number of matches to find (for performance) + * @return Pair of (score, total matched length) where score is sum of squared match lengths + */ +fun calculateContiguousMatchScore( + query: String, + target: String, + totalMatches: Int, +): Pair { + if (query.isEmpty() || target.isEmpty()) return Pair(0, 0) + + val queryLength = query.length + val targetLength = target.length + + var totalScore = 0 + var totalMatchedLength = 0 + var queryIndex = 0 + var matchCount = 0 + var lastTargetIndex = 0 // Track last match position to avoid redundant searches + + while (queryIndex < queryLength && matchCount < totalMatches) { + var bestMatchLength = 0 + var bestTargetIndex = -1 + + // Cache the query character + val queryChar = query[queryIndex] + + // Use indexOf for efficient character search + var targetIndex = target.indexOf(queryChar, lastTargetIndex) + + while (targetIndex != -1 && targetIndex < targetLength) { + // Found potential match start, calculate length inline + var matchLength = 1 + var qi = queryIndex + 1 + var ti = targetIndex + 1 + + // Use direct string access - JVM optimizes this well + while (qi < queryLength && ti < targetLength && query[qi] == target[ti]) { + matchLength++ + qi++ + ti++ + } + + if (matchLength > bestMatchLength) { + bestMatchLength = matchLength + bestTargetIndex = targetIndex + + // Early exit optimization: if this match covers remaining query + if (matchLength == queryLength - queryIndex) { + break + } + } + + // Find next occurrence of queryChar + targetIndex = target.indexOf(queryChar, targetIndex + 1) + } + + if (bestMatchLength >= 2) { + totalScore += (bestMatchLength * bestMatchLength) + totalMatchedLength += bestMatchLength + queryIndex += bestMatchLength + lastTargetIndex = bestTargetIndex + bestMatchLength // Move past this match + matchCount++ + } else { + queryIndex++ + // Reset search position periodically to avoid getting stuck + if (queryIndex % 4 == 0) lastTargetIndex = 0 + } + } + + return Pair(totalScore, totalMatchedLength) +} + +/** + * Calculates a smart file matching score that prioritizes prefix/filename matches and + * contiguous character runs along the full path. Lower values indicate better matches + * (used directly in sorting keys). + * + * @param fileInfo Pair(originalPath, filename). + * @param query The user's search query + * @return A score where lower values indicate better matches + */ +fun calculateFileMatchScore( + fileInfo: Pair, // (originalPath, filename) + query: String, +): Int { + if (query.isBlank()) return 0 + + // Pre-normalize query once + val normalizedQuery = + query + .let { + if (it.startsWith('/')) it.substring(1) else it + }.lowercase() + + // Extract pre-computed values from the Pair + val (originalPath, fileName) = fileInfo + // 1. Exact filename match (highest priority) + if (fileName.equals(normalizedQuery, ignoreCase = true)) { + return -10000 // Early return for exact matches + } + + // 2. Filename suffix match (very high priority) + if (originalPath.endsWith(query, ignoreCase = true)) { + return (-(9000 + normalizedQuery.length * 10)).coerceAtLeast(-9999) + } + + // 3. Filename prefix match (very high priority) + if (fileName.startsWith(normalizedQuery, ignoreCase = true)) { + return (-(8000 + normalizedQuery.length * 10)).coerceAtLeast(-9999) + } + + // 4. Filename contains query (high priority) + val fileNameIndex = fileName.indexOf(normalizedQuery, ignoreCase = true) + if (fileNameIndex >= 0) { + return (-(6000 + ((50 - fileNameIndex) + normalizedQuery.length * 5))).coerceAtLeast(-9999) + } + + // 5. Contiguous matches (fine-grained ranking) + val normalizedPath = + if (File.separator == "/") originalPath.lowercase() else originalPath.replace('\\', '/').lowercase() + + val (contiguousMatchScore, totalMatchedLength) = + calculateContiguousMatchScore( + normalizedQuery, + normalizedPath, + totalMatches = maxOf(2, normalizedQuery.length / 6), + ) + + if (contiguousMatchScore > 0) { + // Scale by the percentage of the target that was matched + val matchPercentage = totalMatchedLength.toDouble() / normalizedPath.length + val scaledScore = (contiguousMatchScore * matchPercentage).toInt() + + val contiguousMatchScoreBonus = scaledScore * 4 + val nonMatchedQueryLengthPenalty = maxOf(0, normalizedPath.length - totalMatchedLength) + return (-(100 + contiguousMatchScoreBonus - nonMatchedQueryLengthPenalty)).coerceAtLeast(-4999) + } + + return 0 +} + +fun getTimeAgo( + timestamp: Long, + granular: Boolean = false, +): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + + return when { + diff < 60_000 -> if (granular) "${diff / 1000}s" else "now" + diff < 3600_000 -> "${diff / 60_000}m" + diff < 86400_000 -> "${diff / 3600_000}h" + diff < 2592000_000 -> "${diff / 86400_000}d" + diff < 31536000_000 -> "${diff / 2592000_000}mo" + else -> "${diff / 31536000_000}y" + } +} + +fun calculateLineCount( + text: String, + width: Int, + fm: FontMetrics, +): Int { + // Replace each tab with 4 spaces (should match textArea.tabSize) + // Note we need to scale this by 3 because 4 tabsize doesn't mean 4 spaces in Swing + // Rather it means 4 character columns which happens to be 3 spaces + val expandedText = text.replace("\t", " ".repeat(3)) + + if (expandedText.isBlank()) return 1 + val words = expandedText.split("\\s+".toRegex()) + var lineCount = 1 + + val leadingWhitespace = expandedText.takeWhile { it.isWhitespace() } + var currentLine = StringBuilder(leadingWhitespace) + + for (word in words) { + var start = 0 + while (start < word.length) { + var end = start + var chunk = "" + while (end < word.length) { + val test = word.substring(start, end + 1) + if (fm.stringWidth(test) > width) break + chunk = test + end++ + } + if (chunk.isEmpty()) { + chunk = word[start].toString() + end = start + 1 + } + + if (currentLine.isNotEmpty() && + fm.stringWidth("$currentLine $chunk") > width + ) { + lineCount++ + currentLine = StringBuilder(chunk) + } else { + if (currentLine.isNotEmpty()) currentLine.append(" ") + currentLine.append(chunk) + } + start = end + } + } + return lineCount +} + +fun getLeadingIndents(s: String): String = s.takeWhile { it.isWhitespace() } + +fun matchIndent( + target: String, + reference: String, +): String { + val targetIndentation = getLeadingIndents(target) + val referenceIndent = getLeadingIndents(reference) + + if (referenceIndent.length <= targetIndentation.length) return target + + val targetLines = target.lines() + + return targetLines.joinToString("\n") { line -> + line.replaceFirst(targetIndentation, referenceIndent) + } +} + +fun computeHash( + text: String, + length: Int = 16, +): String { + val md = MessageDigest.getInstance("SHA-256") + val hashBytes = md.digest(text.toByteArray()) + val fullHash = hashBytes.joinToString("") { "%02x".format(it) } + return fullHash.take(length) +} + +fun getProjectNameHash(project: Project): String = project.name.hashCode().toString() + +infix fun String.matchesIgnoringIndent(other: String): Boolean = matchIndent(this, other) == other + +fun isPlaceholderComment(line: String): Boolean { + val stripped = line.trim() + return (stripped.startsWith("//") || stripped.startsWith("#") || stripped.startsWith("