From aa6b4a66b4af2524e0a35a1ce778875da619c595 Mon Sep 17 00:00:00 2001 From: Andrei Fedotov Date: Thu, 20 Aug 2026 11:48:32 +0300 Subject: [PATCH 1/2] fix LSP document navigation on Windows --- anycode-backend/src/lsp.rs | 54 +++++++++++++++++++++++++++++++++++-- anycode/hooks/useEditors.ts | 35 +++++++++++++----------- 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/anycode-backend/src/lsp.rs b/anycode-backend/src/lsp.rs index d6faa73..f11dc94 100644 --- a/anycode-backend/src/lsp.rs +++ b/anycode-backend/src/lsp.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use tokio::io::{self}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, Notify}; use tokio::sync::mpsc; use tokio::time::Duration; use tokio::time::{self}; @@ -20,6 +20,8 @@ use lsp_types::*; use crate::config::Config; use crate::utils::path_to_uri; +const PROJECT_INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(30); + fn lsp_command(program: &str, args: &[&str]) -> Command { let mut command = Command::new(program); command @@ -82,6 +84,8 @@ pub struct Lsp { pending: Arc>>>, configuration: Arc>, ready: AtomicBool, + project_initialized: Arc, + project_initialization_notify: Arc, opened: HashSet, } @@ -97,6 +101,8 @@ impl Lsp { pending: Arc::new(Mutex::new(HashMap::new())), configuration: Arc::new(Mutex::new(Value::Object(serde_json::Map::new()))), ready: AtomicBool::new(false), + project_initialized: Arc::new(AtomicBool::new(false)), + project_initialization_notify: Arc::new(Notify::new()), opened: HashSet::new(), } } @@ -149,6 +155,8 @@ impl Lsp { let pending = self.pending.clone(); let configuration = self.configuration.clone(); + let project_initialized = self.project_initialized.clone(); + let project_initialization_notify = self.project_initialization_notify.clone(); // reading from child stdout tokio::spawn(async move { @@ -221,6 +229,10 @@ impl Lsp { } match parsed_json.get("method").and_then(|v| v.as_str()) { + Some("workspace/projectInitializationComplete") => { + project_initialized.store(true, Ordering::SeqCst); + project_initialization_notify.notify_waiters(); + } Some("textDocument/publishDiagnostics") => { // diagnostics let v = parsed_json["params"].clone(); @@ -331,6 +343,9 @@ impl Lsp { } self.ready.store(true, Ordering::SeqCst); + if !self.requires_project_initialization() { + self.project_initialized.store(true, Ordering::SeqCst); + } Ok(()) } @@ -358,6 +373,8 @@ impl Lsp { return Err(anyhow::anyhow!("LSP not ready")); } + self.wait_for_project_initialization().await?; + let id = self.get_next_id(); let msg = serde_json::json!({ @@ -391,6 +408,32 @@ impl Lsp { Ok(parsed) } + fn requires_project_initialization(&self) -> bool { + self.lsp_name.as_deref() == Some("roslyn-language-server") + } + + async fn wait_for_project_initialization(&self) -> anyhow::Result<()> { + if !self.requires_project_initialization() { + return Ok(()); + } + + let notification = self.project_initialization_notify.notified(); + if self.project_initialized.load(Ordering::SeqCst) { + return Ok(()); + } + + if time::timeout(PROJECT_INITIALIZATION_TIMEOUT, notification) + .await + .is_err() + { + return Err(anyhow::anyhow!( + "Timed out waiting for LSP project initialization" + )); + } + + Ok(()) + } + pub fn is_ready(&mut self) -> bool { self.ready.load(Ordering::SeqCst) } @@ -406,7 +449,14 @@ impl Lsp { } pub fn did_open(&mut self, lang: &str, path: &str, text: &str) -> Result<()> { - self.opened.insert(path.to_string()); + // A document may be opened by more than one editor pane or by a + // repeated file:open event. LSP requires didOpen to be sent only + // once per document and server connection. Some servers tolerate a + // duplicate notification, while Roslyn terminates the connection. + if !self.opened.insert(path.to_string()) { + return Ok(()); + } + let uri = path_to_uri(path)?; let params = DidOpenTextDocumentParams { text_document: TextDocumentItem { diff --git a/anycode/hooks/useEditors.ts b/anycode/hooks/useEditors.ts index 85d6783..bfc7f0b 100644 --- a/anycode/hooks/useEditors.ts +++ b/anycode/hooks/useEditors.ts @@ -125,9 +125,16 @@ const uriToFilePath = (uriOrPath: string): string => { const rawPath = uriOrPath.slice('file://'.length); try { - return decodeURIComponent(rawPath); + const decodedPath = decodeURIComponent(rawPath); + // file:///C:/... is the canonical URI form for Windows, but the + // local filesystem path must be C:/..., without the URI root slash. + return /^\/[A-Za-z]:\//.test(decodedPath) + ? decodedPath.slice(1) + : decodedPath; } catch { - return rawPath; + return /^\/[A-Za-z]:\//.test(rawPath) + ? rawPath.slice(1) + : rawPath; } }; @@ -1056,19 +1063,17 @@ export const useEditors = ({ wsRef, isConnected, onFileClosed }: UseEditorsParam const range = definition.range; const line = range.start.line; const column = range.start.character; - const filePath = uri.replace('file://', ''); - const fileName = getFileName(filePath); - - const existingFile = filesRef.current.find((f) => f.id === filePath || f.name === fileName); - if (existingFile) { - setActiveFileId(existingFile.id); - const editor = editorRefs.current.get(existingFile.id); - if (editor) { - editor.requestFocus(line, column); - } - } else { - openFile(filePath, line, column); - } + const filePath = uriToFilePath(uri); + // Always route definition navigation through openFile. + // A persisted FileState can exist without a live editor + // instance (for example after a reload); handling that + // state here directly would silently skip file:open. + openFile( + filePath, + line, + column, + activeEditorPaneIdRef.current || DEFAULT_EDITOR_PANE_ID, + ); resolve(definition); } else { From 7d05e00100705aa7e01b6046ae9cc5d592adabb8 Mon Sep 17 00:00:00 2001 From: Andrei Fedotov Date: Mon, 31 Aug 2026 00:32:14 +0300 Subject: [PATCH 2/2] refactor: reuse URI-to-file-path utility --- anycode/App.tsx | 4 ++-- anycode/features/editor/ReferencesPeek.tsx | 13 ++--------- anycode/hooks/useEditors.ts | 26 ++-------------------- anycode/utils.ts | 18 +++++++++++++++ 4 files changed, 24 insertions(+), 37 deletions(-) diff --git a/anycode/App.tsx b/anycode/App.tsx index 3a56dd8..a147238 100644 --- a/anycode/App.tsx +++ b/anycode/App.tsx @@ -34,7 +34,7 @@ import { TerminalPanel } from './features/terminal/TerminalPanel'; import { AgentPanel } from './features/agents/AgentPanel'; import { BrowserPanel } from './features/browser/BrowserPanel'; import { type DiffMode } from './types/diffMode'; -import { normalizePath } from './utils'; +import { normalizePath, uriToFilePath } from './utils'; import { useSettings } from './hooks/useSettings'; const toMultibufferFiles = (files: ChangedFile[]): MultibufferFile[] => files.map((file) => ({ @@ -402,7 +402,7 @@ const App: React.FC = () => { const response = await editors.handleGoToDefinition(request); const definition = Array.isArray(response) ? response[0] : response; if (definition && definition.uri && definition.range) { - const filePath = definition.uri.replace('file://', ''); + const filePath = uriToFilePath(definition.uri); const line = definition.range.start.line; const column = definition.range.start.character; focusReviewFile(paneId, filePath, line, column); diff --git a/anycode/features/editor/ReferencesPeek.tsx b/anycode/features/editor/ReferencesPeek.tsx index e263e59..644f20a 100644 --- a/anycode/features/editor/ReferencesPeek.tsx +++ b/anycode/features/editor/ReferencesPeek.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { AnycodeEditor, AnycodeEditorReact } from 'anycode-react'; import type { ReferencesPeekItem, ReferencesPeekState } from '../../types'; -import { getFileName, getLanguageFromFileName } from '../../utils'; +import { getFileName, getLanguageFromFileName, uriToFilePath } from '../../utils'; import './ReferencesPeek.css'; type ReferenceGroup = { @@ -17,16 +17,7 @@ type ReferencesPeekProps = { }; const resolveItemPath = (item: ReferencesPeekItem): string => { - const source = item.uri || item.file || ''; - if (!source.startsWith('file://')) { - return source; - } - const rawPath = source.slice('file://'.length); - try { - return decodeURIComponent(rawPath); - } catch { - return rawPath; - } + return uriToFilePath(item.uri || item.file || ''); }; let referencesPeekEditorCounter = 0; diff --git a/anycode/hooks/useEditors.ts b/anycode/hooks/useEditors.ts index bfc7f0b..152d5aa 100644 --- a/anycode/hooks/useEditors.ts +++ b/anycode/hooks/useEditors.ts @@ -11,7 +11,7 @@ import { type WatcherEdits, } from '../types'; import { BATCH_DELAY_MS } from '../constants'; -import { getFileName, getLanguageFromFileName } from '../utils'; +import { getFileName, getLanguageFromFileName, uriToFilePath } from '../utils'; import { loadItem, loadOpenFiles, saveItem, saveOpenFiles } from '../storage'; import type { DiffMode } from '../types/diffMode'; import { DEFAULT_DIFF_VIEW_MODE, getNextDiffMode } from '../types/diffMode'; @@ -117,28 +117,6 @@ const normalizeHoverResponse = (response: any): string | null => { return null; }; -const uriToFilePath = (uriOrPath: string): string => { - if (!uriOrPath) return ''; - if (!uriOrPath.startsWith('file://')) { - return uriOrPath; - } - - const rawPath = uriOrPath.slice('file://'.length); - try { - const decodedPath = decodeURIComponent(rawPath); - // file:///C:/... is the canonical URI form for Windows, but the - // local filesystem path must be C:/..., without the URI root slash. - return /^\/[A-Za-z]:\//.test(decodedPath) - ? decodedPath.slice(1) - : decodedPath; - } catch { - return /^\/[A-Za-z]:\//.test(rawPath) - ? rawPath.slice(1) - : rawPath; - } -}; - - const getPersistedActiveFileId = (files: FileState[], activeFileId: string | null): string | null => { if (activeFileId && files.some((file) => file.id === activeFileId)) { return activeFileId; @@ -1290,7 +1268,7 @@ export const useEditors = ({ wsRef, isConnected, onFileClosed }: UseEditorsParam } if (!targetFileId) { - targetFileId = uri.replace('file://', ''); + targetFileId = uriToFilePath(uri); } diagnosticsRef.current.set(targetFileId, diags); diff --git a/anycode/utils.ts b/anycode/utils.ts index 1311517..8f6dc26 100644 --- a/anycode/utils.ts +++ b/anycode/utils.ts @@ -2,6 +2,24 @@ export const normalizePath = (path: string): string => { return path.replace(/\\/g, '/'); }; +export const uriToFilePath = (uriOrPath: string): string => { + if (!uriOrPath || !uriOrPath.startsWith('file://')) return uriOrPath; + + const rawPath = uriOrPath.slice('file://'.length); + try { + const decodedPath = decodeURIComponent(rawPath); + // file:///C:/... is the canonical URI form for Windows, but the + // local filesystem path must be C:/..., without the URI root slash. + return /^\/[A-Za-z]:\//.test(decodedPath) + ? decodedPath.slice(1) + : decodedPath; + } catch { + return /^\/[A-Za-z]:\//.test(rawPath) + ? rawPath.slice(1) + : rawPath; + } +}; + export const getFileName = (path: string): string => { const normalized = normalizePath(path); const parts = normalized.split('/');