diff --git a/.github/workflows/build-universal.yml b/.github/workflows/build-universal.yml index a086a3f..ed23a1c 100644 --- a/.github/workflows/build-universal.yml +++ b/.github/workflows/build-universal.yml @@ -212,11 +212,11 @@ jobs: - name: Place native libs into resources run: | - mkdir -p intellij-plugin/src/main/resources/native - cp temp-artifacts/oxidecode_jvm.linux.so intellij-plugin/src/main/resources/native/oxidecode_jvm_x64.so - cp temp-artifacts/oxidecode_jvm.darwin-arm64.dylib intellij-plugin/src/main/resources/native/oxidecode_jvm_arm64.dylib - cp temp-artifacts/oxidecode_jvm.darwin-x64.dylib intellij-plugin/src/main/resources/native/oxidecode_jvm_x64.dylib - cp temp-artifacts/oxidecode_jvm.win32.dll intellij-plugin/src/main/resources/native/oxidecode_jvm_x64.dll + mkdir -p intellij-plugin-v2/src/main/resources/native + cp temp-artifacts/oxidecode_jvm.linux.so intellij-plugin-v2/src/main/resources/native/oxidecode_jvm_x64.so + cp temp-artifacts/oxidecode_jvm.darwin-arm64.dylib intellij-plugin-v2/src/main/resources/native/oxidecode_jvm_arm64.dylib + cp temp-artifacts/oxidecode_jvm.darwin-x64.dylib intellij-plugin-v2/src/main/resources/native/oxidecode_jvm_x64.dylib + cp temp-artifacts/oxidecode_jvm.win32.dll intellij-plugin-v2/src/main/resources/native/oxidecode_jvm_x64.dll - name: Setup Java uses: actions/setup-java@v4 @@ -226,16 +226,16 @@ jobs: cache: gradle - name: Make gradlew executable - working-directory: intellij-plugin + working-directory: intellij-plugin-v2 run: chmod +x gradlew - name: Build IntelliJ plugin - working-directory: intellij-plugin + working-directory: intellij-plugin-v2 run: ./gradlew clean buildPlugin -PskipNativeCopy --no-daemon --parallel - name: Upload IntelliJ plugin uses: actions/upload-artifact@v4 with: name: oxidecode-intellij-plugin - path: intellij-plugin/build/distributions/*.zip - retention-days: 30 \ No newline at end of file + path: intellij-plugin-v2/build/distributions/*.zip + retention-days: 14 diff --git a/Cargo.lock b/Cargo.lock index 1a4321e..fd12523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1104,7 +1104,7 @@ dependencies = [ [[package]] name = "oxidecode-core" -version = "0.1.0" +version = "0.4.0" dependencies = [ "anyhow", "async-stream", @@ -1124,11 +1124,12 @@ dependencies = [ [[package]] name = "oxidecode-jvm" -version = "0.1.0" +version = "0.4.0" dependencies = [ "jni", "once_cell", "oxidecode-core", + "serde", "serde_json", "tokio", "tokio-util", @@ -1138,7 +1139,7 @@ dependencies = [ [[package]] name = "oxidecode-node" -version = "0.1.0" +version = "0.4.0" dependencies = [ "napi", "napi-build", diff --git a/Cargo.toml b/Cargo.toml index 60ae63c..cb7f6db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.4.0" edition = "2024" authors = ["OxideCode Contributors"] license = "MIT" diff --git a/bindings/jvm/Cargo.toml b/bindings/jvm/Cargo.toml index e8e867c..f0464d6 100644 --- a/bindings/jvm/Cargo.toml +++ b/bindings/jvm/Cargo.toml @@ -14,6 +14,7 @@ jni = "0.21" once_cell = "1" tokio = { workspace = true } tokio-util = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/bindings/jvm/src/lib.rs b/bindings/jvm/src/lib.rs index 61f223b..0baa94a 100644 --- a/bindings/jvm/src/lib.rs +++ b/bindings/jvm/src/lib.rs @@ -2,9 +2,11 @@ use jni::objects::{JClass, JString}; use jni::sys::{jboolean, jint, jstring}; use jni::JNIEnv; use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tokio::runtime::Runtime; use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; @@ -73,6 +75,227 @@ fn parse_prompt_style(s: &str) -> NesPromptStyle { } } +#[derive(Debug, Deserialize)] +struct NextEditPayloadChunk { + #[serde(alias = "file_path", alias = "filePath")] + file_path: String, + #[serde(default, alias = "start_line", alias = "startLine")] + start_line: i32, + #[serde(default, alias = "end_line", alias = "endLine")] + end_line: i32, + content: String, +} + +#[derive(Debug, Deserialize)] +struct NextEditPayloadRequest { + #[serde(alias = "file_path", alias = "filePath")] + file_path: String, + #[serde(alias = "file_contents", alias = "fileContents")] + file_contents: String, + #[serde(alias = "cursor_position", alias = "cursorPosition")] + cursor_position: u32, + #[serde(default, alias = "original_file_contents", alias = "originalFileContents")] + original_file_contents: String, + #[serde(default, alias = "recent_changes", alias = "recentChanges")] + recent_changes: String, + #[serde( + default, + alias = "recent_changes_high_res", + alias = "recentChangesHighRes" + )] + recent_changes_high_res: String, + #[serde(default, alias = "changes_above_cursor", alias = "changesAboveCursor")] + changes_above_cursor: bool, + #[serde(default, alias = "file_chunks", alias = "fileChunks")] + file_chunks: Vec, + #[serde(default, alias = "retrieval_chunks", alias = "retrievalChunks")] + retrieval_chunks: Vec, +} + +#[derive(Debug, Serialize)] +struct NextEditCompletionResponse { + start_index: u32, + end_index: u32, + completion: String, + confidence: f32, + autocomplete_id: String, +} + +#[derive(Debug, Serialize)] +struct NextEditAutocompleteResponse { + start_index: u32, + end_index: u32, + completion: String, + confidence: f32, + autocomplete_id: String, + elapsed_time_ms: u64, + completions: Vec, +} + +fn map_payload_chunk(chunk: NextEditPayloadChunk) -> FileChunk { + FileChunk { + file_path: chunk.file_path, + start_line: chunk.start_line, + end_line: chunk.end_line, + content: chunk.content, + } +} + +fn infer_language_from_path(path: &str) -> String { + let ext = path.rsplit('.').next().unwrap_or_default().to_ascii_lowercase(); + match ext.as_str() { + "rs" => "rust", + "kt" | "kts" => "kotlin", + "java" => "java", + "js" => "javascript", + "ts" => "typescript", + "tsx" => "tsx", + "jsx" => "jsx", + "py" => "python", + "go" => "go", + "cpp" | "cc" | "cxx" | "hpp" | "h" | "c" => "cpp", + "cs" => "csharp", + "swift" => "swift", + "php" => "php", + "rb" => "ruby", + "scala" => "scala", + "lua" => "lua", + "md" => "markdown", + "json" => "json", + "yaml" | "yml" => "yaml", + "xml" => "xml", + "html" | "htm" => "html", + "css" => "css", + "sql" => "sql", + "sh" | "bash" => "shell", + _ => "", + } + .to_string() +} + +fn utf16_offset_to_line_col(text: &str, utf16_offset: u32) -> (u32, u32) { + let mut consumed_units = 0u32; + let mut line = 0u32; + let mut col = 0u32; + + for ch in text.chars() { + let ch_units = ch.len_utf16() as u32; + if consumed_units + ch_units > utf16_offset { + break; + } + consumed_units += ch_units; + if ch == '\n' { + line += 1; + col = 0; + } else { + col += ch_units; + } + } + + (line, col) +} + +fn clamp_to_char_boundary(text: &str, mut offset: usize) -> usize { + offset = offset.min(text.len()); + while offset > 0 && !text.is_char_boundary(offset) { + offset -= 1; + } + offset +} + +fn byte_offset_to_python_index(text: &str, byte_offset: usize) -> u32 { + let clamped = clamp_to_char_boundary(text, byte_offset); + text[..clamped].chars().count() as u32 +} + +fn byte_offset_for_line_col(text: &str, line: u32, col: u32) -> usize { + let mut offset = 0usize; + for (i, segment) in text.split_inclusive('\n').enumerate() { + if i == line as usize { + let visible = segment.strip_suffix('\n').unwrap_or(segment); + let mut units = 0usize; + let mut bytes = 0usize; + for ch in visible.chars() { + let next_units = units + ch.len_utf16(); + if next_units > col as usize { + break; + } + units = next_units; + bytes += ch.len_utf8(); + } + return offset + bytes.min(visible.len()); + } + offset += segment.len(); + } + offset.min(text.len()) +} + +fn byte_offset_to_line_col(text: &str, byte_offset: usize) -> (u32, u32) { + let clamped = clamp_to_char_boundary(text, byte_offset); + let prefix = &text[..clamped]; + let line = prefix.bytes().filter(|b| *b == b'\n').count() as u32; + let col = prefix + .rsplit('\n') + .next() + .map(|s| s.chars().count() as u32) + .unwrap_or(0); + (line, col) +} + +fn build_delta_from_original( + filepath: &str, + original: &str, + current: &str, +) -> Option { + if original == current { + return None; + } + + let mut prefix = 0usize; + let shared_prefix_limit = original.len().min(current.len()); + while prefix < shared_prefix_limit && original.as_bytes()[prefix] == current.as_bytes()[prefix] { + prefix += 1; + } + prefix = clamp_to_char_boundary(original, prefix); + prefix = clamp_to_char_boundary(current, prefix); + + let mut original_suffix = original.len(); + let mut current_suffix = current.len(); + while original_suffix > prefix + && current_suffix > prefix + && original.as_bytes()[original_suffix - 1] == current.as_bytes()[current_suffix - 1] + { + original_suffix -= 1; + current_suffix -= 1; + } + + original_suffix = clamp_to_char_boundary(original, original_suffix); + current_suffix = clamp_to_char_boundary(current, current_suffix); + + let removed = original[prefix..original_suffix].to_string(); + let inserted = current[prefix..current_suffix].to_string(); + if removed.is_empty() && inserted.is_empty() { + return None; + } + + let (start_line, start_col) = byte_offset_to_line_col(original, prefix); + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + Some(EditDelta { + filepath: filepath.to_string(), + start_line, + start_col, + start_offset: None, + removed, + inserted, + file_content: current.to_string(), + timestamp_ms, + }) +} + /// Initialise the tracing subscriber once for JNI. Call from the Java side early /// (for example when the plugin / extension starts) to enable debug logging. #[unsafe(no_mangle)] @@ -199,6 +422,182 @@ pub extern "system" fn Java_com_oxidecode_CoreBridge_getCompletion( env.new_string(out).unwrap().into_raw() } +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_oxidecode_CoreBridge_fetchNextEditAutocomplete( + mut env: JNIEnv, + _class: JClass, + base_url: JString, + api_key: JString, + model: JString, + nes_prompt_style: JString, + request_json: JString, + debug_log_dir: JString, + request_id: JString, +) -> jstring { + let base_url: String = env.get_string(&base_url).unwrap().into(); + let api_key: String = env.get_string(&api_key).unwrap().into(); + let model: String = env.get_string(&model).unwrap().into(); + let nes_prompt_style: String = env.get_string(&nes_prompt_style).unwrap().into(); + let request_json: String = env.get_string(&request_json).unwrap().into(); + let debug_log_dir: String = env.get_string(&debug_log_dir).unwrap().into(); + let request_id: String = env.get_string(&request_id).unwrap().into(); + + let started = Instant::now(); + let cancel = CancellationToken::new(); + let _request_guard = RequestGuard::new(request_id.clone(), cancel.clone()); + let request: NextEditPayloadRequest = match serde_json::from_str(&request_json) { + Ok(req) => req, + Err(error) => { + warn!( + error = %error, + "Failed to parse next-edit payload JSON in fetchNextEditAutocomplete" + ); + return env.new_string("").unwrap().into_raw(); + } + }; + + let (cursor_line, cursor_col) = + utf16_offset_to_line_col(&request.file_contents, request.cursor_position); + let language = infer_language_from_path(&request.file_path); + let prompt_style = parse_prompt_style(&nes_prompt_style); + let api_key_opt = if api_key.is_empty() { + None + } else { + Some(api_key) + }; + let calibration_log_dir_opt = if debug_log_dir.is_empty() { + None + } else { + Some(debug_log_dir) + }; + + info!( + base_url = %base_url, + model = %model, + prompt_style = ?prompt_style, + filepath = %request.file_path, + cursor_position = request.cursor_position, + cursor_line = cursor_line, + cursor_col = cursor_col, + "Java_com_oxidecode_CoreBridge_fetchNextEditAutocomplete called" + ); + + let provider = Arc::new(OmniProvider::new_openai_compat( + &base_url, + api_key_opt, + &model, + Option::::None, + )); + let nes_cfg = NesConfig { + prompt_style, + completion_endpoint: CompletionEndpoint::Completions, + calibration_log_dir: calibration_log_dir_opt, + ..NesConfig::default() + }; + let engine = NesEngine::new(provider, nes_cfg); + + if let Some(delta) = build_delta_from_original( + &request.file_path, + &request.original_file_contents, + &request.file_contents, + ) { + engine.push_edit(delta); + } + + let file_chunks: Vec = request + .file_chunks + .into_iter() + .map(map_payload_chunk) + .collect(); + let retrieval_chunks: Vec = request + .retrieval_chunks + .into_iter() + .map(map_payload_chunk) + .collect(); + let high_res_deltas: Vec = Vec::new(); + + let hint = runtime().block_on(engine.predict( + &request.file_path, + cursor_line, + cursor_col, + request.cursor_position, + &request.file_contents, + &language, + if request.original_file_contents.is_empty() { + None + } else { + Some(request.original_file_contents.as_str()) + }, + if request.recent_changes.is_empty() { + None + } else { + Some(request.recent_changes.as_str()) + }, + Some(&file_chunks), + Some(&retrieval_chunks), + if request.recent_changes_high_res.is_empty() { + None + } else { + Some(request.recent_changes_high_res.as_str()) + }, + Some(&high_res_deltas), + request.changes_above_cursor, + false, + cancel, + )); + + let response_json = hint.and_then(|hint| { + let start_byte = byte_offset_for_line_col( + &request.file_contents, + hint.position.line, + hint.position.col, + ); + let end_byte = if let Some(selection) = hint.selection_to_remove.as_ref() { + byte_offset_for_line_col( + &request.file_contents, + selection.end_line, + selection.end_col, + ) + } else { + start_byte + }; + let start_index = byte_offset_to_python_index(&request.file_contents, start_byte); + let end_index = byte_offset_to_python_index(&request.file_contents, end_byte); + let confidence = hint.confidence.unwrap_or(1.0); + + let completion = NextEditCompletionResponse { + start_index, + end_index, + completion: hint.replacement, + confidence, + autocomplete_id: format!("{}-0", request_id), + }; + let response = NextEditAutocompleteResponse { + start_index: completion.start_index, + end_index: completion.end_index, + completion: completion.completion.clone(), + confidence: completion.confidence, + autocomplete_id: completion.autocomplete_id.clone(), + elapsed_time_ms: started.elapsed().as_millis() as u64, + completions: vec![completion], + }; + serde_json::to_string(&response).ok() + }); + + match &response_json { + Some(json) => { + debug!(len = json.len(), "fetchNextEditAutocomplete returned response"); + } + None => { + warn!("fetchNextEditAutocomplete produced no prediction"); + } + } + + env.new_string(response_json.unwrap_or_default()) + .unwrap() + .into_raw() +} + // ─── NES ───────────────────────────────────────────────────────────────────── /// `OxideCore.predictNextEdit(baseUrl, apiKey, model, completionModel, nesPromptStyle, deltasJson, highResDeltasJson, fileChunksJson, changesAboveCursor, cursorFile, cursorLine, cursorCol, fileContent, language, completionEndpoint, originalFileContent, calibrationLogDir) -> String (JSON NesHint)` @@ -221,6 +620,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 +694,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 +727,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 +745,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/config.rs b/core/src/config.rs index a6ea887..8c33b2a 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -29,6 +29,13 @@ pub enum CompletionEndpoint { ChatCompletions, } +pub enum TokenFamily { + Generic, + Zeta1, + Zeta2, + Sweep, +} + /// Selects which prompt format the NES engine uses when querying the model. /// /// - `Generic` — the original OxideCode format: asks the model for a JSON diff --git a/core/src/nes/delta.rs b/core/src/nes/delta.rs index 8ea8f77..6111377 100644 --- a/core/src/nes/delta.rs +++ b/core/src/nes/delta.rs @@ -30,6 +30,8 @@ pub struct EditDelta { pub timestamp_ms: u64, } +// + impl EditDelta { /// Whether this delta represents a meaningful, non-trivial edit worth /// tracking for NES purposes (filters out single-whitespace changes etc.) 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) } diff --git a/intellij-plugin/src/main/resources/META-INF/plugin.xml b/intellij-plugin-old/src/main/resources/META-INF/plugin.xml similarity index 100% rename from intellij-plugin/src/main/resources/META-INF/plugin.xml rename to intellij-plugin-old/src/main/resources/META-INF/plugin.xml diff --git a/intellij-plugin/src/main/resources/META-INF/pluginIcon.svg b/intellij-plugin-old/src/main/resources/META-INF/pluginIcon.svg similarity index 100% rename from intellij-plugin/src/main/resources/META-INF/pluginIcon.svg rename to intellij-plugin-old/src/main/resources/META-INF/pluginIcon.svg diff --git a/intellij-plugin/src/main/resources/META-INF/pluginIcon_dark.svg b/intellij-plugin-old/src/main/resources/META-INF/pluginIcon_dark.svg similarity index 100% rename from intellij-plugin/src/main/resources/META-INF/pluginIcon_dark.svg rename to intellij-plugin-old/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..7e0144e --- /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") + bundledPlugins("Git4Idea") + } + 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 0000000..8bdaf60 Binary files /dev/null and b/intellij-plugin-v2/gradle/wrapper/gradle-wrapper.jar differ 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/CoreBridge.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/CoreBridge.kt new file mode 100644 index 0000000..07b081d --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/CoreBridge.kt @@ -0,0 +1,77 @@ +package com.oxidecode + +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import java.io.File +import java.nio.file.Files +import java.util.UUID + +@Service +class CoreBridge { + init { + loadNativeLibrary() + try { + initLogging() + LOG.info("OxideCode native logging initialized") + } catch (e: Throwable) { + LOG.warn("Failed to initialize native logging: ${e.message}", e) + } + } + + external fun initLogging() + + external fun cancelRequest(requestId: String) + + external fun fetchNextEditAutocomplete( + baseUrl: String, + apiKey: String, + model: String, + nesPromptStyle: String, + requestJson: String, + debugLogDir: String, + requestId: String, + ): String + + fun newRequestId(prefix: String): String = "$prefix-${UUID.randomUUID()}" + + companion object { + private val LOG: Logger = Logger.getInstance(CoreBridge::class.java) + private var loaded = false + + private fun loadNativeLibrary() { + if (loaded) return + + 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 archTag = + when { + arch.contains("aarch64") || arch.contains("arm") -> "arm64" + else -> "x64" + } + + val resourcePath = "/native/oxidecode_jvm_${archTag}.$ext" + val stream = + CoreBridge::class.java.getResourceAsStream(resourcePath) + ?: error("Native library not found in jar: $resourcePath") + + val tempDir = Files.createTempDirectory("oxidecode").toFile() + val tempLib = File(tempDir, "oxidecode_jvm.$ext") + tempLib.deleteOnExit() + stream.use { input -> + tempLib.outputStream().use { output -> + input.copyTo(output) + } + } + + System.load(tempLib.absolutePath) + loaded = true + } + } +} 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..39d62bc --- /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, + * ActionPromoter 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, + * ActionPromoter 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..4bf107b --- /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 OxideCode 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..b271fdf --- /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.OxideCodeLogo + 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.OxideCodeLogo + + 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..fad735d --- /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 = "OxideCode Notifications", + 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..9dcebbe --- /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 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() + } + } + } + } + }, 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..a0bc36a --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/data/Models.kt @@ -0,0 +1,710 @@ +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 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 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 + * CustomGeneralTextSnippet- + * 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 ErrorRequest( + 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..222c801 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/AutocompleteIpResolverService.kt @@ -0,0 +1,213 @@ +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.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.oxidecode.CoreBridge +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.defaultJson +import com.oxidecode.utils.encodeString +import kotlinx.coroutines.* +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 the given hostname + * 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 = "localhost" + 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 requestJson = encodeString(request, NextEditAutocompleteRequest.serializer()) + val bridge = service() + val settings = OxideCodeSettings.getInstance() + val requestId = bridge.newRequestId("next-edit") + val responseJson = + withContext(Dispatchers.IO) { + bridge.fetchNextEditAutocomplete( + getBaseUrl(), + settings.anthropicApiKey, + settings.model, + settings.nesPromptStyle, + requestJson, + settings.debugLogDir, + requestId, + ) + } + if (responseJson.isBlank()) { + null + } else { + defaultJson.decodeFromString(responseJson) + } + } 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..be61741 --- /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 OxideCodeProjectService 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..c3a03b6 --- /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("OxideCodeFileUsage.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..bb14c54 --- /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 TAB_MAPPING = "map :action com.oxidecode.autocomplete.edit.AcceptEditCompletionAction" + private const val MAPPING_COMMENT = "\" OxideCode 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 OxideCode Tab mapping already exists in .ideavimrc + */ + private fun hasOxideCodeTabMapping(ideavimrcFile: File): Boolean { + if (!ideavimrcFile.exists()) { + return false + } + + return try { + val content = ideavimrcFile.readText() + content.contains(TAB_MAPPING) + } catch (e: Exception) { + logger.warn("Error reading .ideavimrc file", e) + false + } + } + + /** + * Adds the OxideCode Tab mapping to the .ideavimrc file + */ + private fun addOxideCodeTabMapping(ideavimrcFile: File) { + try { + val mappingWithComment = "\n$MAPPING_COMMENT\n$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 OxideCode Tab mapping to .ideavimrc") + } catch (e: Exception) { + logger.warn("Error adding OxideCode 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("OxideCode Notifications") + .createNotification( + "IdeaVim Integration Complete", + "OxideCode 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 (hasOxideCodeTabMapping(ideavimrcFile)) { + logger.info("OxideCode Tab mapping already exists in .ideavimrc") + return@executeOnPooledThread + } + + logger.info("IdeaVim detected, adding OxideCode Tab mapping to .ideavimrc") + addOxideCodeTabMapping(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..4778ad7 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/NotificationDeduplicationService.kt @@ -0,0 +1,298 @@ +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 settings and update your token." + } + + // HTTP 403 - Forbidden + message.contains("HTTP 403") -> { + "Authentication Error" to + "Your credentials for are misconfigured. Please check your settings and try again." + } + + // HTTP 404 - Not Found + message.contains("HTTP 404") -> { + "Service Unavailable" to "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 + "OxideCode cannot connect because your network proxy requires authentication. " + + "Please configure your proxy credentials in Settings > Appearance & Behavior > System Settings > HTTP Proxy" + } + + // HTTP 429 - Too Many Requests + message.contains("HTTP 429") -> { + "Rate Limited" to "Too many requests sent. 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 "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 the 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. " + } + + // Default case - use original message but make it more user-friendly + else -> { + originalTitle to + "An error occurred while using OxideCode. 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..4d9a9f5 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/services/SessionMessageList.kt @@ -0,0 +1,329 @@ +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 chat. + * + * This is a non-service class that holds the messages, conversation state, and metadata + * for a single chat session. Each Session owns one instance of this class. + * + * Thread-safety: All operations are guarded by a ReentrantReadWriteLock. + * + * @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 Session 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/OxideCodeConfig.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeConfig.kt new file mode 100644 index 0000000..b5c6c67 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeConfig.kt @@ -0,0 +1,303 @@ +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.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.util.Disposer +import com.intellij.util.messages.MessageBusConnection +import com.intellij.util.messages.Topic +import com.intellij.util.ui.JBUI +import com.intellij.util.xmlb.XmlSerializerUtil +import java.awt.event.ActionListener +import javax.swing.* + +/** + * Enum representing the bash command auto-approve mode. + * - ASK_EVERY_TIME: Always ask for user confirmation before running bash commands + * - RUN_EVERYTHING: Auto-approve all bash commands without confirmation (except those in blocklist) + * - USE_ALLOWLIST: Only auto-approve commands that match patterns in the allowlist + */ +enum class BashAutoApproveMode( + val displayName: String, +) { + ASK_EVERY_TIME("Ask Every Time"), + RUN_EVERYTHING("Run Everything"), + USE_ALLOWLIST("Use Allowlist"), + ; + + override fun toString(): String = displayName +} + +data class OxideCodeConfigState( + var rules: String = "", + var customRules: String = "", + var fontSize: Float = + JBUI.Fonts + .label() + .size + .toFloat(), + var enableEntitySuggestions: Boolean = true, + var showExamplePrompts: Boolean = true, + var hasSetExamplePrompts: Boolean = false, + var selectedTemplate: String? = null, + var selectedRulesFile: String? = null, + var useCustomizedCommitMessages: Boolean = true, + var noEntitiesCache: Boolean = false, + var enableUserActionsTracking: Boolean = false, + var showTerminalCommandInput: Boolean = false, + @Deprecated("Privacy mode enabled is now stored in OxideCodeMetaData as an IDE level setting") + var privacyModeEnabled: Boolean = true, + var autoApprovedTools: Set = + setOf( + "list_files", + "read_file", + "search_files", + "find_usages", + "get_errors", + "str_replace", + "create_file", + ), + // Bash auto-approve mode: ASK_EVERY_TIME, RUN_EVERYTHING (except blocklist), or USE_ALLOWLIST + var bashAutoApproveMode: String = BashAutoApproveMode.ASK_EVERY_TIME.name, + var windowsGitBashPath: String = "", + var debounceThresholdMs: Long = 10L, // effectively zero + var autocompleteDebounceMs: Long = -1L, // autocomplete-specific debounce, -1 means not initialized + var disabledMcpServers: Set = emptySet(), + var disabledMcpTools: Map> = emptyMap(), + var errorToolMinSeverity: String = "ERROR", // Default to ERROR (current behavior) + var showCurrentPlanSections: Boolean = false, + var gateStringReplaceInChat: Boolean = false, + var enableBashTool: Boolean = true, // Default to enabled + var runBashToolInBackground: Boolean = true, // Run bash commands in background process instead of terminal + @Deprecated("Automatically disable conflicting autocomplete plugins is now stored in OxideCodeSettings as an IDE level setting") + var disableConflictingPlugins: Boolean = true, + // Show autocomplete badge (Tab to accept hint) + var showAutocompleteBadge: Boolean = false, + // Autocomplete exclusion patterns - files matching these patterns won't trigger autocomplete + var autocompleteExclusionPatterns: Set = emptySet(), + // V2 of autocomplete exclusion patterns - added to ensure all users get .env excluded by default + // The getter merges v1 and v2 patterns, so existing users keep their patterns and get .env added + var autocompleteExclusionPatternsV2: Set = setOf(".env"), + // Bash command allowlist - commands matching these patterns will be auto-approved + var bashCommandAllowlist: Set = emptySet(), + // Bash command blocklist - commands matching these patterns will always require confirmation + var bashCommandBlocklist: Set = setOf("rm"), + // BYOK (Bring Your Own Key) settings - Map of provider -> (apiKey, eligibleModels) + // DEPRECATED: BYOK is now stored at application level in OxideCodeSettings + @Deprecated("BYOK is now stored at application level in OxideCodeSettings. This field is kept for migration only.") + var byokProviderConfigs: MutableMap = mutableMapOf(), + // Token usage indicator - show/hide tokens and cost details + var showTokenDetails: Boolean = true, + // Autocomplete local mode - for development/testing + var isAutocompleteLocalMode: Boolean = false, + // MCP tools UI - whether to show MCP tool inputs in Tool Calling UI tooltips + var showMcpToolInputsInTooltips: Boolean = false, + // Whether to hide the autocomplete exclusion banner (user clicked "Don't show again") + var hideAutocompleteExclusionBanner: Boolean = false, + // Whether to always keep thinking blocks expanded (don't auto-collapse after completion) + var alwaysExpandThinkingBlocks: Boolean = false, + // Maximum number of concurrent chat tabs (1-6, default 3) + var maxTabs: Int = 3, + // Whether web search is enabled by default for new chats + var webSearchEnabledByDefault: Boolean = true, +) + +@State( + name = "com.oxidecode.components.OxideCodeConfig", + storages = [Storage("OxideCodeConfig.xml")], +) +@Service(Service.Level.PROJECT) +class OxideCodeConfig( + private val project: Project, +) : PersistentStateComponent, + Disposable { + companion object { + fun getInstance(project: Project): OxideCodeConfig = project.getService(OxideCodeConfig::class.java) + + // Add topic for auto-approve bash changes + val AUTO_APPROVE_BASH_TOPIC = + Topic.create( + "OxideCodeAutoApproveBash", + AutoApproveBashListener::class.java, + ) + } + + // Add listener interface for auto-approve bash changes + interface AutoApproveBashListener { + fun onAutoApproveBashChanged(enabled: Boolean) + } + + private var state = OxideCodeConfigState() + private var connection: MessageBusConnection? = null + private var settingsUpdateCallback: ((OxideCodeSettings) -> Unit)? = null + private var mcpStatusUpdateCallback: (() -> Unit)? = null + private var mcpServersPanel: JPanel? = null + private var mcpServerStatusContainer: JPanel? = null + private var tabbedPane: JTabbedPane? = null + private var configDialog: DialogWrapper? = null + private var dialogDisposable: Disposable? = null + private var privacyModeCheckBox: JCheckBox? = null + private var privacyModeActionListener: ActionListener? = null + + init { + // Create the connection once during initialization + connection = ApplicationManager.getApplication().messageBus.connect(this) + connection?.subscribe( + OxideCodeSettings.SettingsChangedNotifier.TOPIC, + OxideCodeSettings.SettingsChangedNotifier { + // Use ApplicationManager.getApplication().invokeLater for immediate UI updates + ApplicationManager.getApplication().invokeLater { + // Update UI components with new values + val updatedSettings = OxideCodeSettings.getInstance() + settingsUpdateCallback?.invoke(updatedSettings) + } + }, + ) + } + + /** + * Migrates BYOK settings from project level to application level. + * This is a one-time migration that only occurs if: + * 1. Project-level BYOK has configured providers with API keys + * 2. Application-level BYOK is empty (no providers configured yet) + * + * After migration, the project-level BYOK data is cleared to avoid confusion. + */ + @Suppress("DEPRECATION") + private fun migrateBYOKToApplicationLevel() { + val settings = OxideCodeSettings.getInstance() + + // Check if project level has BYOK data with actual API keys configured + val projectBYOK = state.byokProviderConfigs + val hasProjectBYOKData = projectBYOK.isNotEmpty() && projectBYOK.values.any { it.apiKey.isNotEmpty() } + + // Check if application level is empty (no providers with API keys) + val appBYOK = settings.byokProviderConfigs + val hasAppBYOKData = appBYOK.isNotEmpty() && appBYOK.values.any { it.apiKey.isNotEmpty() } + + // Only migrate if project has data and app doesn't + if (hasProjectBYOKData && !hasAppBYOKData) { + // Migrate each provider config from project to application level + for ((provider, config) in projectBYOK) { + if (config.apiKey.isNotEmpty()) { + settings.byokProviderConfigs[provider] = + BYOKProviderConfig( + apiKey = config.apiKey, + eligibleModels = config.eligibleModels, + ) + } + } + + // Clear project-level BYOK data after successful migration + state.byokProviderConfigs.clear() + } + } + + override fun getState(): OxideCodeConfigState = state + + override fun loadState(state: OxideCodeConfigState) { + XmlSerializerUtil.copyBean(state, this.state) + // Migrate BYOK settings from project level to application level + // This must happen after loadState() so that the persisted project-level data is available + migrateBYOKToApplicationLevel() + } + + fun isEntitySuggestionsEnabled(): Boolean = state.enableEntitySuggestions + + fun isPrivacyModeEnabled(): Boolean = OxideCodeMetaData.getInstance().privacyModeEnabled + + // Bash auto-approve mode methods + fun getBashAutoApproveMode(): BashAutoApproveMode = + try { + BashAutoApproveMode.valueOf(state.bashAutoApproveMode) + } catch (_: IllegalArgumentException) { + BashAutoApproveMode.ASK_EVERY_TIME + } + + fun updateBashAutoApproveMode(mode: BashAutoApproveMode) { + state.bashAutoApproveMode = mode.name + // Notify that auto-approve bash setting has changed + project.messageBus.syncPublisher(AUTO_APPROVE_BASH_TOPIC).onAutoApproveBashChanged(mode == BashAutoApproveMode.RUN_EVERYTHING) + } + + @Deprecated("Use getBashAutoApproveMode() instead", ReplaceWith("getBashAutoApproveMode() == BashAutoApproveMode.RUN_EVERYTHING")) + fun isAutoApproveBashCommandsEnabled(): Boolean = getBashAutoApproveMode() == BashAutoApproveMode.RUN_EVERYTHING + + @Deprecated("Use updateBashAutoApproveMode() instead") + fun updateAutoApproveBashCommandsEnabled(enabled: Boolean) { + updateBashAutoApproveMode(if (enabled) BashAutoApproveMode.RUN_EVERYTHING else BashAutoApproveMode.ASK_EVERY_TIME) + } + + fun getDebounceThresholdMs(): Long { + // IDE-wide storage: delegate to OxideCodeSettings with one-time migration from project state + val settings = OxideCodeSettings.getInstance() + + // One-time migration: if app-level is unset (-1), migrate from existing project-level state + if (settings.autocompleteDebounceMs <= 0L) { + val migrated = + when { + // Prefer the newer project-level field if it was set + state.autocompleteDebounceMs != -1L -> state.autocompleteDebounceMs + // Fall back to older debounceThresholdMs if it was meaningfully set (>200 as per prior logic) + state.debounceThresholdMs > 200L -> state.debounceThresholdMs + else -> 20L + } + settings.autocompleteDebounceMs = migrated.coerceIn(20L, 5000L) + } + + return settings.autocompleteDebounceMs + } + + // Autocomplete badge visibility + fun isShowAutocompleteBadge(): Boolean = state.showAutocompleteBadge + + // Autocomplete exclusion patterns + // Returns the union of v1 and v2 patterns to ensure existing users get .env added + fun getAutocompleteExclusionPatterns(): Set = state.autocompleteExclusionPatterns + state.autocompleteExclusionPatternsV2 + + fun updateAutocompleteExclusionPatterns(patterns: Set) { + // Store in v2 field, clear v1 to avoid duplication + state.autocompleteExclusionPatternsV2 = patterns + state.autocompleteExclusionPatterns = emptySet() + } + + fun isAutocompleteLocalMode(): Boolean = OxideCodeSettings.getInstance().autocompleteLocalMode + + private fun cleanupDialogResources() { + // Remove privacy mode checkbox listener + privacyModeCheckBox?.removeActionListener(privacyModeActionListener) + privacyModeCheckBox = null + privacyModeActionListener = null + + // Clear callback references to prevent memory leaks + settingsUpdateCallback = null + mcpStatusUpdateCallback = null + + // Clear UI component references + mcpServersPanel = null + mcpServerStatusContainer = null + tabbedPane = null + + // Dispose of the dialog disposable to clean up child components + dialogDisposable?.let { disposable -> + if (!Disposer.isDisposed(disposable)) { + Disposer.dispose(disposable) + } + } + dialogDisposable = null + + // Clear dialog reference + configDialog = null + } + + override fun dispose() { + cleanupDialogResources() + connection?.disconnect() + connection = null + } +} diff --git a/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeConfigurable.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeConfigurable.kt new file mode 100644 index 0000000..18ac9cb --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeConfigurable.kt @@ -0,0 +1,121 @@ +package com.oxidecode.settings + +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurationException +import com.intellij.openapi.project.ProjectManager +import com.intellij.util.ui.FormBuilder +import javax.swing.JComboBox +import javax.swing.JCheckBox +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JPasswordField +import javax.swing.JScrollPane +import javax.swing.JSpinner +import javax.swing.JTextArea +import javax.swing.JTextField +import javax.swing.SpinnerNumberModel + +class OxideCodeConfigurable : Configurable { + private val settings = OxideCodeSettings.getInstance() + + private val baseUrlField = JTextField(40) + private val apiKeyField = JPasswordField(40) + private val modelField = JTextField(40) + private val exclusionPatternsArea = JTextArea(6, 40) + private val nextEditSuggestionsEnabledBox = JCheckBox("Enable Next Edit Suggestions") + private val nesDebounceMsField = JSpinner(SpinnerNumberModel(50, 20, 5000, 10)) + private val nesPromptStyleField = JComboBox(arrayOf("generic", "zeta1", "zeta2", "sweep")) + private val debugLogsDirField = JTextField(40) + + override fun getDisplayName(): String = "OxideCode" + + override fun createComponent(): JComponent { + nesPromptStyleField.isEditable = true + reset() + return FormBuilder + .createFormBuilder() + .addLabeledComponent("Provider base URL:", baseUrlField) + .addLabeledComponent("API key (empty for local models):", apiKeyField) + .addLabeledComponent("Model:", modelField) + .addLabeledComponent("Autocomplete exclusion patterns:", JScrollPane(exclusionPatternsArea)) + .addSeparator() + .addComponent(nextEditSuggestionsEnabledBox) + .addLabeledComponent("NES debounce (ms):", nesDebounceMsField) + .addLabeledComponent("NES prompt style:", nesPromptStyleField) + .addLabeledComponent("Debug logs dir (empty = off):", debugLogsDirField) + .addComponentFillVertically(JPanel(), 0) + .panel + } + + override fun isModified(): Boolean { + val settingsChanged = + baseUrlField.text.trim() != settings.baseUrl || + String(apiKeyField.password) != settings.anthropicApiKey || + modelField.text.trim() != settings.model || + nextEditSuggestionsEnabledBox.isSelected != settings.nextEditPredictionFlagOn || + ((nesDebounceMsField.value as Int).toLong()) != effectiveDebounceMs() || + (nesPromptStyleField.selectedItem as String) != settings.nesPromptStyle || + debugLogsDirField.text.trim() != settings.debugLogDir + + val exclusionPatternsChanged = + normalizePatternText(exclusionPatternsArea.text) != normalizePatternText(getExclusionPatternsText()) + + return settingsChanged || exclusionPatternsChanged + } + + @Throws(ConfigurationException::class) + override fun apply() { + settings.baseUrl = baseUrlField.text.trim() + settings.anthropicApiKey = String(apiKeyField.password) + settings.model = modelField.text.trim() + settings.nextEditPredictionFlagOn = nextEditSuggestionsEnabledBox.isSelected + settings.autocompleteDebounceMs = (nesDebounceMsField.value as Int).toLong() + settings.nesPromptStyle = nesPromptStyleField.selectedItem as String + settings.debugLogDir = debugLogsDirField.text.trim() + + val updatedPatterns = + normalizePatternText(exclusionPatternsArea.text) + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .toSet() + + ProjectManager.getInstance().openProjects.forEach { project -> + OxideCodeConfig.getInstance(project).updateAutocompleteExclusionPatterns(updatedPatterns) + } + } + + override fun reset() { + baseUrlField.text = settings.baseUrl + apiKeyField.text = settings.anthropicApiKey + modelField.text = settings.model + exclusionPatternsArea.text = getExclusionPatternsText() + nextEditSuggestionsEnabledBox.isSelected = settings.nextEditPredictionFlagOn + nesDebounceMsField.value = effectiveDebounceMs().toInt() + nesPromptStyleField.selectedItem = settings.nesPromptStyle + debugLogsDirField.text = settings.debugLogDir + } + + private fun getExclusionPatternsText(): String { + val project = ProjectManager.getInstance().openProjects.firstOrNull() ?: return "" + return OxideCodeConfig + .getInstance(project) + .getAutocompleteExclusionPatterns() + .toList() + .sorted() + .joinToString("\n") + } + + private fun effectiveDebounceMs(): Long { + val value = settings.autocompleteDebounceMs + if (value <= 0L) return 50L + return value.coerceIn(20L, 5000L) + } + + private fun normalizePatternText(raw: String): String = + raw + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .joinToString("\n") +} 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..aa8b9a2 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeMetaData.kt @@ -0,0 +1,131 @@ +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 (OxideCodeConfig) + 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 privacyModeEnabled: Boolean + get() = metaData.privacyModeEnabled + set(value) { + metaData.privacyModeEnabled = value + } + + var autocompleteAcceptCount: Int + get() = metaData.ghostTextTabAcceptCount + set(value) { + metaData.ghostTextTabAcceptCount = value + } + + var hasUsedLookupItem: Boolean + get() = metaData.hasUsedLookupItem + set(value) { + metaData.hasUsedLookupItem = 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..9b41032 --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/settings/OxideCodeSettings.kt @@ -0,0 +1,243 @@ +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_URL = "" + 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_MODEL = "sweep-next-edit-v2-7B" + private const val DEFAULT_NES_PROMPT_STYLE = "sweep" + private const val DEFAULT_DEBUG_LOG_DIR = "" + + // -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("OxideCode settings changed", SettingsChangedNotifier::class.java) + } + } + + + var baseUrl: String = DEFAULT_URL + get() = field.trim().trimEnd('/') + set(value) { + if (value != field) { + field = value + notifySettingsChanged() + } else { + 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 model: String = DEFAULT_MODEL + get() = field.trim() + set(value) { + field = value + } + + var nesPromptStyle: String = DEFAULT_NES_PROMPT_STYLE + get() = field.trim() + set(value) { + field = value + } + + var debugLogDir: String = DEFAULT_DEBUG_LOG_DIR + get() = field.trim() + 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 OxideCodeConfig when first accessed. + */ + var autocompleteDebounceMs: Long = + DEFAULT_AUTOCOMPLETE_DEBOUNCE_MS + set(value) { + val clamped = value.coerceIn(20L, 5000L) + 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/startup/OxideCodeStartupActivity.kt b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/startup/OxideCodeStartupActivity.kt new file mode 100644 index 0000000..bf0af5f --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/startup/OxideCodeStartupActivity.kt @@ -0,0 +1,17 @@ +package com.oxidecode.startup + +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.oxidecode.autocomplete.edit.RecentEditsTracker + +/** + * Minimal startup bootstrap for edit autocomplete. + */ +class OxideCodeStartupActivity : ProjectActivity, DumbAware { + override suspend fun execute(project: Project) { + if (project.isDisposed) return + RecentEditsTracker.getInstance(project) + } +} + 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..1969b41 --- /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("oxidecode.originalIcon") as? javax.swing.Icon + ?: currentIcon.also { child.putClientProperty("oxidecode.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("oxidecode.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..86d1e26 --- /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") + + // OxideCode rules accent color - static as it's a brand color + val oxideCodeRulesAccentColor: JBColor = JBColor(Color(88, 157, 246), Color(104, 159, 244)) + + // Dropdown panel background based on current background + val oxideCodeDropdownPanelBackground: 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..b02ddcd --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/theme/OxideCodeIcons.kt @@ -0,0 +1,19 @@ +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 OxideCodeLogo get() = loadIcon("/icons/oxide_code_logo.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..89b9a5f --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/EditorUtils.kt @@ -0,0 +1,614 @@ +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) + } + } +} + +/** + * 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..c6b6709 --- /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.withOxideCodeFont( + 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..66f7c9a --- /dev/null +++ b/intellij-plugin-v2/src/main/kotlin/com/oxidecode/utils/OxideCodeConstants.kt @@ -0,0 +1,2195 @@ +package com.oxidecode.utils + +import com.intellij.openapi.extensions.PluginId +import com.intellij.ui.JBColor +import java.awt.Color + +object OxideCodeConstants { + const val PLUGIN_ID_KEY = "plugin.id" + + 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 + } + + const val FILE_PLACEHOLDER = "" + const val GENERAL_TEXT_SNIPPET_PREFIX = "OxideCodeCustomGeneralTextSnippet-" + + const val MAX_FILE_SIZE_MB = 2.56 + const val MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 // 2.56MB in bytes + + val diffFiles = setOf("TabPreviewDiffVirtualFile", "Diff") + + // 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) + + 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 EXTENSION_TO_LANGUAGE: Map = + LANGUAGE_EXTENSIONS.entries + .flatMap { (language, extensions) -> + extensions.map { extension -> extension to language } + }.toMap() + + 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") + + 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", + ) + + @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, + ) +} 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..27480f8 --- /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 "messageaction" → "commitmessageaction". + * + * @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("