//! One open project folder: documents, index, language servers, repositories, //! watchers and the per-project services, opened by [`crate::Host::open_project`]. use crate::Host; use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink}; use ide_index::ProjectIndex; use ide_language::{FileTypes, LanguageRegistry, SyntaxCache}; use ide_lsp::LspManager; use ide_syntax::StructureService; use ide_text::{AppliedChange, ByteEdit}; use ide_vcs::{Repo, Repos}; use ide_vfs::Watcher; use ide_workspace::documents::DocumentObserver; use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder}; use parking_lot::{Mutex, RwLock}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; /// Everything bound to one open project folder. pub struct Project { pub(crate) trusted: Arc, pub root: PathBuf, /// Window-scoped event sink, for commands that emit project events. pub sink: SharedSink, /// The app's language definitions with this project's File Types applied. pub languages: Arc, pub settings: Arc, pub documents: Arc, pub history: Arc, /// Undo stack of file move / rename refactorings. pub moves: FileMoves, pub index: Arc, pub lsp: Arc, /// Git repositories: the one containing the project root and those nested below it. pub repos: Arc>, /// Explicitly opened external logs remain read-only and outside project indexing. pub(crate) external_log_repos: RwLock>>, /// Incrementally maintained syntax trees of the open documents. pub syntax: Arc, /// Tree-sitter structure queries (LSP fallback) over `syntax`. pub structure: Arc, /// Roots auto-detected from build files; see [`Project::roots`]. pub detected_roots: Arc>>, /// Per-project cache directory (build tool sync results…). pub cache_dir: PathBuf, /// Superseded launch snapshots retained until this project session ends. pub(crate) java_launch_snapshots: Mutex, /// Database tool window: data sources and their connections. pub db: Arc, /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. pub http_history: Arc, /// Coding agent tasks (Agents tool window). pub agents: Arc, /// Native notebooks and owned kernels, scoped to this project incarnation. pub notebooks: Arc, pub(crate) kotlin_bridge: Option>, pub(crate) watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, /// Watchers of folders outside the project that language servers registered file /// watchers for (a virtualenv elsewhere, a path dependency). lsp_watchers: Mutex>, pub(crate) _scratch_watcher: Option, /// A LightEdit window's loose files ([`Host::open_light`]): global settings only, /// nothing indexed, no language servers, repositories or project services. pub light: bool, } impl Host { pub fn open_project(&self, window: &str, root: &Path) -> IdeResult> { let started = std::time::Instant::now(); let mut lap = started; let mut laps: Vec<(&str, u128)> = Vec::new(); let mut mark = |name: &'static str| { laps.push((name, lap.elapsed().as_millis())); lap = std::time::Instant::now(); }; let root = dunce::canonicalize(root)?; // Reject a damaged manifest before replacing the currently open project. ide_workspace::workspace::Workspace::load(&root)?; // The window reloaded (or asked again) for the project it already has: keep // it, with its warm index, language servers and unsaved documents. if let Ok(current) = self.project(window) && current.root == root && !current.light { return Ok(current); } self.close_project(window); self.wait_detached_closes(); let sink: SharedSink = (self.sink_for)(window); // Before anything the project starts: its children get the project's environment. let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?)); let post_start = trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten(); let settings = SettingsStore::load(&self.config_dir, Some(&root)); let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root))); let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read()); let build_cache = self.local_data_dir.join("cache").join(project_key(&root)).join("build"); let extra_roots = content_roots(&settings.effective(), &root, &build_cache); mark("roots"); // The project's own registry: File Types associations may be project-scoped. let languages = Arc::new(self.languages.fork()); languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root))); let documents = documents(&sink, &languages, Some(&root)); documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root))); let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?); { let history = history.clone(); let retention = history_retention(&settings.effective()); std::thread::Builder::new().name("openidea-history-prune".into()).spawn(move || { if let Err(e) = history.apply_retention(retention) { tracing::warn!("local history: {e}"); } })?; } let project_key = project_key(&root); let index = ProjectIndex::open( &root, &self.local_data_dir.join("index").join(&project_key), index_roots(&root, extra_roots.clone()), excludes.clone(), languages.clone(), self.syntax(), sink.clone(), )?; mark("index"); let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone()); lsp.set_execution_allowed(trusted.load(Ordering::Acquire)); lsp.set_content_roots(extra_roots.clone()); let settings = Arc::new(settings); { let settings = settings.clone(); let root = root.clone(); let data_dir = self.local_data_dir.clone(); let config_dir = self.config_dir.clone(); let build_cache = self.local_data_dir.join("cache").join(&project_key).join("build"); let sink = sink.clone(); let formatter_problem = std::sync::Mutex::new(None); lsp.set_config_hook(Box::new(move |cfg, server_root| { let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| { w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count()) }); let effective = member .as_ref() .map(|p| SettingsStore::load(&config_dir, Some(p)).effective()) .unwrap_or_else(|| settings.effective()); let root = member.as_ref().unwrap_or(&root); ide_sdk::apply::server_launch_overrides(cfg, &effective); ide_sdk::apply::configure_server(cfg, root, server_root, &effective); if cfg.name == "jdtls" { // A workspace member keeps its build model under its own key (Build Project). let cache = match &member { Some(member) => build_cache.join(crate::project::project_key(member)), None => build_cache.clone(), }; let profiles = ide_build::maven_profiles(&cache); ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache); } ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks()); if cfg.name == "jdtls" { let problem = ide_lsp::jdtls::configure_formatter( cfg, effective.editor.code_style.languages.get("java"), root, &data_dir, effective.language_servers.get("jdtls"), ) .err() .map(|e| e.to_string()); let mut previous = formatter_problem.lock().unwrap(); if problem != *previous { if let Some(body) = &problem { sink.emit(IdeEvent::Notify { level: ide_core::event::NotifyLevel::Warning, title: "Java formatter configuration unavailable".into(), body: Some(body.clone()), }); } *previous = problem; } drop(previous); ide_lsp::jdtls::configure_source_paths( cfg, ide_workspace::roots::unmanaged_java_source_paths( root, server_root, &effective.project_structure, ), ); let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug") .into_iter() .chain(ide_sdk::java::usages_bundle(&data_dir)) .chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir)) .collect(); let min_java = ide_sdk::java::jdtls_min_java(cfg); let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir); let lombok = ide_sdk::lombok::lombok_agent( server_root, ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)), ide_tools::managed_bundle(&data_dir, "lombok"), ide_sdk::apply::maven_local_repository(root, &effective), ); if let Some(problem) = lombok.problem() { tracing::warn!("{problem}"); } let server_jdk = ide_sdk::apply::jdtls_jdk(root, &effective).unwrap_or_else(|e| { cfg.launch_error = Some(e); None }); ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections); ide_sdk::java::configure_jdtls( cfg, server_root, &data_dir, &jdks, ide_sdk::apply::project_jdk(root, &effective).as_ref(), server_jdk.as_ref(), min_java, &bundles, lombok.jar(), ); ide_sdk::jdtls::launch_directly(cfg); } ide_sdk::apply::server_protocol_overrides(cfg, &effective); // After the overrides: the PHP version they may set decides the storage. ide_sdk::php::configure_intelephense_storage(cfg, &data_dir); if cfg.name == "jdtls" { let (excluded_poms, build_files) = jdtls_maven_import(root, &effective); ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref()); ide_sdk::apply::configure_gradle_import( cfg, effective.languages.java.gradle.linked_projects.as_deref(), build_files.as_deref(), ); } })); } { let settings = settings.clone(); let root = root.clone(); let data_dir = self.local_data_dir.clone(); let sink = sink.clone(); lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| { let effective = settings.effective(); if cfg.name == "jdtls" { crate::java_runtime::prepare( cfg, &data_dir, !effective.languages.java.jdtls_jdk.trim().is_empty() || ide_sdk::apply::jdtls_jdk(&root, &effective).is_err(), &sink, cancel, ); } })); } { let settings = settings.clone(); lsp.set_servers_hook(Box::new(move |language, defined| { ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective()) })); } { let documents = documents.clone(); lsp.set_apply_edit_hook(Box::new(move |edit| apply_workspace_edit(&documents, &edit).is_ok())); } let syntax = Arc::new(SyntaxCache::new(self.syntax())); documents.add_observer(Arc::new(SyntaxObserver(syntax.clone()))); documents.add_observer(Arc::new(LspObserver(lsp.clone()))); documents.add_observer(Arc::new(HistoryObserver(history.clone()))); documents.add_observer(Arc::new(DeployObserver { remote: self.remote.clone(), settings: settings.clone(), root: root.clone(), sink: sink.clone(), runtime: self.runtime.clone(), trusted: trusted.clone(), })); let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new( root.clone(), build_cache.clone(), &self.local_data_dir, settings.clone(), lsp.clone(), sink.clone(), trusted.clone(), ); documents.add_observer(kotlin_bridge.clone()); let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); for repo in repos.read().all() { repo.set_execution_allowed(trusted.load(Ordering::Acquire)); } let watcher = { let kotlin_bridge = kotlin_bridge.clone(); let repos = repos.clone(); let settings = settings.clone(); let index = index.clone(); let documents = documents.clone(); let languages = languages.clone(); let sink = sink.clone(); let root = root.clone(); let detected_roots = detected_roots.clone(); let history = history.clone(); let remote = self.remote.clone(); let runtime = self.runtime.clone(); let lsp = lsp.clone(); let trusted = trusted.clone(); let build_cache = build_cache.clone(); Watcher::start( &root.clone(), excludes.clone(), sink.clone(), Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); let (ignored, ignored_gone) = ignored_changes(batch); index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat()); kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs); if composer_changed(&root, changed.iter().chain(&batch.removed)) { lsp.reconfigure_soon(); } // Atomic saves by external tools surface as `created`. documents.on_disk_changed(&[&changed[..], &ignored].concat()); // A framework's root marker (`angular.json`) created or deleted: its // templates gain or lose their mode. Markers in `node_modules` only // count here when the folder isn't excluded (it is by default). if batch.created.iter().chain(&batch.removed).any(|p| languages.is_framework_marker(p)) { documents.redetect_languages(); } record_external_changes(&history, &changed, &batch.removed); if trusted.load(Ordering::Acquire) && !batch.removed.is_empty() { delete_deployed( &runtime, remote.clone(), settings.clone(), root.clone(), batch.removed.clone(), sink.clone(), ); } // Build files / conventional folders changed: re-detect roots. let touched = changed.iter().chain(&batch.removed).filter_map(|p| p.strip_prefix(&root).ok()); if touched.into_iter().any(ide_workspace::roots::affects_roots) { let fresh = ide_workspace::roots::detect(&root); if *detected_roots.read() != fresh { // Detected excluded folders (`target`, `build`) leave the index too. let effective = settings.effective(); index.set_scope( index_roots(&root, content_roots(&effective, &root, &build_cache)), exclude_globs(&root, &effective, &fresh), ); *detected_roots.write() = fresh; sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); } } // A repository was cloned / initialised / removed below the project: // its `.git/HEAD` appeared or went. A linked worktree's `.git` is a // plain gitfile, and that file is the only marker written inside the // worktree itself, so the `.git` path counts too. let known = repos.read().roots(); let mut git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| { let dir = if p.ends_with(".git/HEAD") { p.parent().and_then(Path::parent) } else if p.ends_with(".git") { p.parent() } else { None }; let Some(dir) = dir else { return false }; known.iter().any(|r| r == dir) != p.exists() }); // A linked worktree was registered or unregistered in the main // gitdir: `git worktree add` / `remove` creates or deletes // `.git/worktrees/`. Its own gitfile is written before the // watcher can watch the new directory, so this entry is what // Linux per-directory watchers see. git_dirs |= batch.vcs.iter().any(|p| { let rest: Vec<_> = p .components() .skip_while(|c| c.as_os_str() != ".git") .skip(1) // the `.git` component itself .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect(); matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2) }); if git_dirs { scan_repositories(&root, &repos, &settings, &sink, &build_cache); } let vcs_moved = batch.vcs.iter().any(|p| vcs_path_changed(p)); if git_dirs || vcs_moved { sink.emit(IdeEvent::VcsChanged { root: root.clone().into() }); } }), )? }; if let Some(reason) = watcher.polling() { sink.emit(IdeEvent::Notify { level: ide_core::event::NotifyLevel::Warning, title: "External file changes sync may be slow".into(), body: Some(reason.into()), }); } // Scratch files live outside the project; watch them too so the // Project view and open editors follow changes made there. let scratch_watcher = { let dir = self.data_dir.join("scratches"); let documents = documents.clone(); std::fs::create_dir_all(&dir).ok().and_then(|()| { Watcher::start( &dir, Vec::new(), sink.clone(), Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); documents.on_disk_changed(&changed); }), ) .map_err(|e| tracing::warn!("scratches watcher: {e}")) .ok() }) }; let content_watchers = extra_roots .iter() .filter_map(|r| { let w = content_root_watcher( r, &excludes, &sink, &index, &documents, &history, &lsp, Some(&kotlin_bridge), )?; Some((r.clone(), w)) }) .collect(); mark("watcher"); { let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone()); let build_cache = build_cache.clone(); std::thread::Builder::new() .name("openidea-vcs-roots".into()) .spawn(move || scan_repositories(&root, &repos, &settings, &sink, &build_cache))?; } mark("git"); tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis()); let (db, http_history, agents) = self.project_services(&root, &project_key); let notebooks = crate::notebook::NotebookService::new( self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); ide_agent::registry::maintain(crate::ops::agent::registry_paths(self)); let project = Arc::new(Project { trusted, root, sink, languages, settings, documents, history, moves: FileMoves::new(), index, lsp, repos, external_log_repos: RwLock::default(), structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots, cache_dir: self.local_data_dir.join("cache").join(&project_key), java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( self.local_data_dir.join("cache").join(&project_key).join("build"), )), db, http_history, agents, notebooks, kotlin_bridge: Some(kotlin_bridge), watcher, content_watchers: Mutex::new(content_watchers), lsp_watchers: Mutex::new(Vec::new()), _scratch_watcher: scratch_watcher, light: false, }); { let weak = Arc::downgrade(&project); project.lsp.set_candidates_hook(Box::new(move |q, cancel| { weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default() })); } { let weak = Arc::downgrade(&project); project.lsp.set_file_watch_roots_hook(Box::new(move |roots| { if let Some(p) = weak.upgrade() { p.watch_for_language_servers(roots); } })); } { let weak = Arc::downgrade(&project); project.lsp.set_project_excludes_hook(Box::new(move || { weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default() })); } self.insert_project(window, project.clone()); if let Some(bridge) = &project.kotlin_bridge { bridge.start(&self.runtime); } if let Some(cmd) = post_start { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); } Ok(project) } /// Database connections, HTTP Client history and coding agents of the project at `root`. pub(crate) fn project_services( &self, root: &Path, project_key: &str, ) -> (Arc, Arc, Arc) { let db = Arc::new( ide_db::DbManager::new( root.to_path_buf(), self.data_dir.join("db-passwords.json"), self.data_dir.join("db-history.json"), self.ssh.clone(), ) .with_global_sources(self.data_dir.clone()), ); let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key))); let agents = Arc::new(ide_agent::AgentManager::new( ide_agent::AgentPaths { project_root: root.to_path_buf(), store_dir: self.data_dir.join("agents").join(project_key), worktrees_dir: agent_worktrees_dir(root, || { // Outside the app data dir: its path has spaces on macOS, which trips up build scripts. dirs::home_dir() .unwrap_or_else(|| self.data_dir.clone()) .join(".openidea") .join("worktrees") .join(project_key) }), models_file: self.data_dir.join("agents").join("models.json"), custom_agents_dir: Some(self.config_dir.join("agents")), home_dir: dirs::home_dir(), }, self.runtime.clone(), )); (db, http_history, agents) } } impl Host { /// Opens `window` for editing loose files (LightEdit), `file` first: documents /// with syntax highlighting, saving and Local History over the global settings, /// without an index, language servers, repositories or project settings. A /// window already in LightEdit keeps its session; only its watchers grow. pub fn open_light(&self, window: &str, file: &Path) -> IdeResult> { let file = dunce::canonicalize(file)?; if file.is_dir() { return Err(IdeError::invalid(format!("{} is a folder", file.display()))); } let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?; if let Ok(current) = self.project(window) && current.light { current.watch_light_dir(&dir); return Ok(current); } self.close_project(window); self.wait_detached_closes(); let sink: SharedSink = (self.sink_for)(window); let settings = Arc::new(SettingsStore::load(&self.config_dir, None)); let effective = settings.effective(); let languages = Arc::new(self.languages.fork()); languages.set_file_types(FileTypes::from_settings(&effective.files, None)); let documents = documents(&sink, &languages, None); documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, None)); let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &dir)?); let index = ProjectIndex::unindexed(&dir, languages.clone(), self.syntax(), sink.clone())?; let lsp = LspManager::new(dir.clone(), languages.clone(), sink.clone()); lsp.set_servers_hook(Box::new(|_, _| Vec::new())); let syntax = Arc::new(SyntaxCache::new(self.syntax())); documents.add_observer(Arc::new(SyntaxObserver(syntax.clone()))); documents.add_observer(Arc::new(HistoryObserver(history.clone()))); let watcher = light_watcher(&dir, &sink, &documents, &history)?; let project_key = project_key(&dir); let (db, http_history, agents) = self.project_services(&dir, &project_key); let notebooks = crate::notebook::NotebookService::new( self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); let project = Arc::new(Project { trusted: Arc::new(AtomicBool::new(false)), sink, languages, settings, documents, history, moves: FileMoves::new(), index, lsp, repos: Arc::default(), external_log_repos: RwLock::default(), structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots: Arc::default(), cache_dir: self.local_data_dir.join("cache").join(&project_key), java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( self.local_data_dir.join("cache").join(&project_key).join("build"), )), db, http_history, agents, notebooks, kotlin_bridge: None, watcher, content_watchers: Mutex::new(Vec::new()), lsp_watchers: Mutex::new(Vec::new()), _scratch_watcher: None, light: true, root: dir, }); tracing::info!("window {window} edits {} without a project", file.display()); self.insert_project(window, project.clone()); Ok(project) } } impl Project { pub fn is_trusted(&self) -> bool { self.trusted.load(Ordering::Acquire) } pub fn require_trust(&self) -> IdeResult<()> { if self.is_trusted() { Ok(()) } else { Err(IdeError::unavailable("Project is in Restricted Mode. Trust the project to enable execution.")) } } /// LightEdit: follow external changes to the files of `dir` (its own entries only). pub fn watch_light_dir(&self, dir: &Path) { if !self.light { return; } let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); let mut watchers = self.content_watchers.lock(); if dir == self.root || watchers.iter().any(|(p, _)| *p == dir) { return; } match light_watcher(&dir, &self.sink, &self.documents, &self.history) { Ok(w) => watchers.push((dir, w)), Err(e) => tracing::warn!("watching {}: {e}", dir.display()), } } } /// The documents of a project whose languages and UI modes `languages` detects, /// looking for frameworks' root markers up to `root` (anywhere for loose files). fn documents(sink: &SharedSink, languages: &Arc, root: Option<&Path>) -> Arc { let (detect, modes, root) = (languages.clone(), languages.clone(), root.map(Path::to_path_buf)); Arc::new( Documents::new(sink.clone(), Box::new(move |p: &Path, first: Option<&str>| detect.detect(p, first))) .with_ui_mode_detection(Box::new(move |p: &Path, lang: &LanguageId| { modes.framework_ui_mode(lang, p, root.as_deref()) })), ) } fn light_watcher( dir: &Path, sink: &SharedSink, documents: &Arc, history: &Arc, ) -> IdeResult { let (documents, history) = (documents.clone(), history.clone()); Watcher::start_with( dir, Vec::new(), sink.clone(), Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); documents.on_disk_changed(&changed); record_external_changes(&history, &changed, &batch.removed); }), ide_vfs::WatchMode::Flat, ) } /// Looks for repositories nested below the project (and the one containing it); /// emits `VcsChanged` when the set of roots changed. fn scan_repositories( root: &Path, repos: &RwLock, settings: &SettingsStore, sink: &SharedSink, build_cache: &Path, ) { let ignored = settings.effective().ignored_names(); let skip = |p: &Path| { p.file_name().is_some_and(|n| { let name = n.to_string_lossy(); ignored.is_ignored_name(&name) || (ide_workspace::unity::GENERATED_DIRS.contains(&name.as_ref()) && p.parent().is_some_and(ide_workspace::unity::is_project)) }) }; let mut roots = vec![root.to_path_buf()]; roots.extend(content_roots(&settings.effective(), root, build_cache)); match Repos::discover_roots(&roots, &skip) { Ok(found) => { if found.roots() != repos.read().roots() { *repos.write() = found; sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() }); } } Err(e) => tracing::warn!("looking for git repositories: {e}"), } } impl Project { pub fn refresh_repositories(&self) { scan_repositories(&self.root, &self.repos, &self.settings, &self.sink, &self.build_cache_dir()); } /// The project's git repositories. pub fn repos(&self) -> Repos { let repos = self.repos.read().clone(); for repo in repos.all() { repo.set_execution_allowed(self.is_trusted()); } repos } /// Runs `f` with the repository whose root is `at`, the project's primary repository /// when `None`; fails when there is none (a repository-scoped command never falls back /// to another repository). pub fn with_repo_at(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { let repos = self.repos(); match at { Some(root) => match repos.at_root(root) { Some(repo) => self.run_in(repo, f), None => { Err(IdeError::unavailable(format!("{} is not a git repository of the project", root.display()))) } }, None => match repos.primary() { Some(repo) => self.run_in(repo, f), None => Err(IdeError::unavailable("project is not a git repository")), }, } } pub fn open_external_log(&self, path: &Path) -> IdeResult { let root = dunce::canonicalize(path)?; let repo = Repo::discover(&root)?.ok_or_else(|| IdeError::unavailable("folder is not a Git worktree"))?; let root = repo.root().to_path_buf(); repo.set_execution_allowed(false); let mut external = self.external_log_repos.write(); if !external.iter().any(|r| r.root() == root) { external.push(Arc::new(repo)); } Ok(root) } /// Log read operations may also use an explicitly opened external worktree. /// Mutations continue to use `with_repo_at` and cannot reach these repositories. pub fn with_log_repo_at(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { if let Some(path) = at && self.repos().at_root(path).is_none() { let repo = self .external_log_repos .read() .iter() .find(|r| r.root() == path) .cloned() .ok_or_else(|| IdeError::unavailable("external Git log is not open"))?; return f(&repo); } self.with_repo_at(at, f) } pub fn with_log_repo_of(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { if self.repos().for_path(path).is_some() { return self.with_repo_of(path, f); } let repo = self .external_log_repos .read() .iter() .filter(|r| path.starts_with(r.root())) .max_by_key(|r| r.root().components().count()) .cloned() .ok_or_else(|| IdeError::unavailable("path is not in an open Git log"))?; f(&repo) } /// Runs `f` with the repository `path` (a file or folder) belongs to. pub fn with_repo_of(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { match self.repos().for_path(path) { Some(repo) => self.run_in(repo, f), None => Err(IdeError::unavailable(format!("{} is not in a git repository", path.display()))), } } fn run_in(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { let settings = self.settings.effective(); if self.is_trusted() { apply_git_executable(&settings); } repo.set_ignored_names(settings.ignored_names()); f(repo) } /// Runs `f` with the project's primary git repository, or fails when the project is not under git. pub fn with_repo(&self, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { self.with_repo_at(None, f) } pub(crate) fn with_disk_sync(&self, f: impl FnOnce() -> IdeResult) -> IdeResult { let result = f(); // Failed operations can also leave changed files (for example merge conflicts). self.documents.sync_after_disk_change(); result } /// Runs a VCS operation that rewrites the working tree (checkout, merge, /// rebase, pull, reset, stash, rollback): Local History gets a label and a /// snapshot of every locally changed file before, and a label after. /// Clean buffers follow the resulting files before returning; `None` omits the history labels. pub fn with_repo_recorded( &self, at: Option<&Path>, operation: Option<&str>, f: impl FnOnce(&Repo) -> IdeResult, ) -> IdeResult { let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) }; self.history_label(&format!("Before {name}")); if let Ok(changed) = self.with_repo_at(at, |r| r.status()) { for file in changed.iter().take(HISTORY_BATCH_LIMIT) { if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) { tracing::debug!("local history: {e}"); } } } let result = self.with_disk_sync(|| self.with_repo_at(at, f)); self.history_label(&format!("After {name}")); result } fn history_label(&self, label: &str) { if let Err(e) = self.history.put_label(&self.root, label) { tracing::warn!("local history: {e}"); } } /// Local History snapshots of `paths` before a refactoring changes them. pub fn record_before_refactoring(&self, paths: &[&Path], name: &str) { let label = format!("Before refactoring: {name}"); for path in paths { let result = match self.documents.text_of(path) { Some(text) => self.history.record_event(path, &text, &label), None => self.history.record_file(path, &label, true).map(|_| ()), }; if let Err(e) = result { tracing::debug!("local history: {e}"); } } } /// Effective source / test / resource / excluded roots (detected + Project Structure marks). pub fn roots(&self) -> ProjectRoots { let settings = self.settings.effective(); let detected = with_gradle_roots( &self.root, &self.build_cache_dir(), &settings.languages.java.gradle, &self.detected_roots.read(), ); linked_roots(&self.root, &settings, &detected) } /// Where the Maven / Gradle tool windows cache what the build tools reported. pub fn build_cache_dir(&self) -> PathBuf { self.cache_dir.join("build") } /// File masks (`ide_search::FileMask`) for Find in Files and inspections: the /// excluded roots, the ignored names and the "Exclude files" patterns (files, /// and everything under such folders). pub fn search_excludes(&self) -> Vec { let mut out: Vec = self.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect(); let settings = self.settings.effective(); let names = settings.files.ignored_names.iter().cloned().chain(settings.project_structure.exclude_file_patterns()); for name in names { let name = name.trim(); if !name.is_empty() && !name.contains(['/', ',']) { out.push(name.to_owned()); out.push(format!("**/{name}/**")); } } out } /// Project files of `q.languages` under `q.root` containing `q.word` as a whole /// word (unsaved buffers included; excluded roots, ignored names and /// `.gitignore`d folders skipped, while `.gitignore`d files elsewhere count, as /// in IDEA's project scope): the files a references search opens on its server. pub fn reference_candidates( &self, q: &ide_lsp::CandidateQuery, cancel: &CancellationToken, ) -> ide_lsp::CandidateFiles { let root = self .content_roots() .into_iter() .chain(std::iter::once(self.root.clone())) .filter(|root| q.root.starts_with(root)) .max_by_key(|root| root.components().count()); let Some(root) = root else { return ide_lsp::CandidateFiles::default(); }; let query = ide_search::WordQuery { word: q.word.clone(), dir: q.root.clone(), exclude: self.search_excludes(), limit: q.limit, }; let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None)); match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) { Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit }, Err(e) => { tracing::debug!("reference candidates: {}", e.message); ide_lsp::CandidateFiles::default() } } } /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). pub fn exclude_globs(&self) -> Vec { exclude_globs(&self.root, &self.settings.effective(), &self.detected_roots.read()) } /// Project Structure content roots besides the project folder (existing directories). pub fn content_roots(&self) -> Vec { content_roots(&self.settings.effective(), &self.root, &self.build_cache_dir()) } /// Applies Settings › File Types; open documents whose language changed are /// re-detected (language servers and syntax trees follow) and the index /// re-parses the files whose language changed. pub fn apply_file_types(&self, settings: &ide_core::settings::Settings) { let before = Arc::new(self.languages.snapshot()); if self.languages.set_file_types(FileTypes::from_settings(&settings.files, Some(&self.root))) { self.documents.redetect_languages(); self.index.languages_changed(before); } } /// Applies changed content roots and exclude settings to the index (which /// rescans in the background) and the file watchers, without reopening the project. pub fn refresh_index_scope(&self) { if self.light { return; } let (roots, excludes) = (self.content_roots(), self.exclude_globs()); self.lsp.set_content_roots(roots.clone()); self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone()); self.watcher.set_excludes(&excludes); let mut watchers = self.content_watchers.lock(); watchers.retain(|(p, _)| roots.contains(p)); for (_, w) in watchers.iter() { w.set_excludes(&excludes); } for r in roots { if watchers.iter().any(|(p, _)| *p == r) { continue; } if let Some(w) = content_root_watcher( &r, &excludes, &self.sink, &self.index, &self.documents, &self.history, &self.lsp, self.kotlin_bridge.as_ref(), ) { watchers.push((r, w)); } } } /// Use the open document's selected encoding, or the same detection/settings as opening it. pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult { let charset = self.file_encoding(path, rev)?; let encoding = ide_vcs::TextEncoding::from(charset); Ok(if self.documents.native_to_ascii_for(path) { encoding.with_ascii_conversion(ide_vfs::decode_properties_escapes, ide_vfs::encode_properties_ascii) } else { encoding }) } fn file_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> { if let Some(id) = self.documents.by_path(path) { let info = self.documents.snapshot(id)?; return encoding_rs::Encoding::for_label(info.encoding.as_bytes()) .ok_or_else(|| ide_core::IdeError::invalid("unknown document encoding")); } let defaults = self.documents.file_defaults(); let fallback = defaults.fallback_for(path); if path.exists() { let loaded = ide_vfs::load_text_with_fallback(path, fallback)?; return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()) .ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding")); } let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default(); Ok(ide_vfs::detect_encoding(&bytes, fallback)) } /// Current text of `path`: the open buffer when available, else the file on disk. pub fn text_of(&self, path: &Path) -> IdeResult { match self.documents.text_of(path) { Some(text) => Ok(text), None => Ok(ide_text::normalize_line_endings(&self.documents.load_file(path)?.text).into_owned()), } } } /// Applies a multi-file edit through the document layer so open editors update live. pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> { documents.apply_workspace_edit(edit) } /// Files the watcher reports per batch that Local History snapshots. const HISTORY_BATCH_LIMIT: usize = 500; /// Local History for changes made outside the IDE (other tools, VCS, deletions). /// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped. /// The batch's ignored files (`.env`), split into those still on disk and those removed. fn ignored_changes(batch: &ide_vfs::FsBatch) -> (Vec, Vec) { batch.ignored.iter().cloned().partition(|p| p.symlink_metadata().is_ok()) } fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) { for path in changed.iter().take(HISTORY_BATCH_LIMIT) { if let Err(e) = history.record_file(path, "External change", false) { tracing::debug!("local history: {e}"); } } if !removed.is_empty() && let Err(e) = history.note_removed(removed) { tracing::debug!("local history: {e}"); } } pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspace::Retention { ide_workspace::Retention { working_days: settings.files.local_history_days, max_bytes: u64::from(settings.files.local_history_max_mb.max(1)) * 1024 * 1024, } } /// A workspace uses its root only for state, never as indexed content. fn index_roots(root: &Path, folders: Vec) -> ide_index::IndexRoots { ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders } } /// Workspace members in manifest order, or extra Project Structure directories and the /// Gradle project folders outside the project as of the last sync (`build_cache`). fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec { if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) { return workspace.projects; } let mut out: Vec = Vec::new(); let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim())); for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) { if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) { out.push(p); } } out } /// Watches a content root outside the project: the index, open documents and /// Local History follow its changes like the project folder's. impl Project { /// Watches `roots` (outside the project) for the language servers that asked to. fn watch_for_language_servers(&self, roots: Vec) { let mut watchers = self.lsp_watchers.lock(); watchers.retain(|(r, _)| roots.contains(r)); for root in roots { if watchers.iter().any(|(r, _)| *r == root) || !root.is_dir() { continue; } let lsp = Arc::downgrade(&self.lsp); let on_change = Box::new(move |batch: &ide_vfs::FsBatch| { if let Some(lsp) = lsp.upgrade() { lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs); } }); // Not project files: nothing else hears of them. match Watcher::start(&root, Vec::new(), Arc::new(ide_core::NullSink), on_change) { Ok(w) => watchers.push((root, w)), Err(e) => tracing::warn!("language server watcher {}: {e}", root.display()), } } } } #[allow(clippy::too_many_arguments)] fn content_root_watcher( dir: &Path, excludes: &[String], sink: &SharedSink, index: &Arc, documents: &Arc, history: &Arc, lsp: &Arc, kotlin_bridge: Option<&Arc>, ) -> Option { let kotlin_bridge = kotlin_bridge.cloned(); let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone()); let root = dir.to_path_buf(); Watcher::start( dir, excludes.to_vec(), sink.clone(), Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); let (ignored, ignored_gone) = ignored_changes(batch); index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat()); // A workspace member's PHP language level follows its own composer.json. if composer_changed(&root, changed.iter().chain(&batch.removed)) { lsp.reconfigure_soon(); } if let Some(bridge) = &kotlin_bridge { bridge.files_changed(changed.iter().chain(&batch.removed)); } documents.on_disk_changed(&[&changed[..], &ignored].concat()); record_external_changes(&history, &changed, &batch.removed); }), ) .map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display())) .ok() } /// Whether the `composer.json` of `root` is among `paths`: the PHP language level follows it /// (`ide_sdk::php`). fn composer_changed<'a>(root: &Path, mut paths: impl Iterator) -> bool { let composer = root.join("composer.json"); paths.any(|p| *p == composer) } /// Where agent tasks create their worktrees: next to the project, `/.worktrees`, /// so they sit on the project's drive and outside its tree (not indexed, watched or shown in /// its git status); `fallback` for a project at a filesystem root. fn agent_worktrees_dir(root: &Path, fallback: impl FnOnce() -> PathBuf) -> PathBuf { match (root.parent(), root.file_name()) { (Some(parent), Some(name)) => { let mut dir = name.to_os_string(); dir.push(".worktrees"); parent.join(dir) } _ => fallback(), } } pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Settings) -> Vec { ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven).poms().map(PathBuf::from).collect() } /// The unlinked Maven POMs jdtls leaves out, and the build files (`file:` URIs) it is to /// import when that takes more than exclusions: an unlinked module of a linked reactor. /// Those then include the Gradle build, which jdtls would otherwise find by itself. pub(crate) fn jdtls_maven_import( root: &Path, settings: &ide_core::settings::Settings, ) -> (Vec, Option>) { let maven = &settings.languages.java.maven; let gradle = &settings.languages.java.gradle; if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() { return (Vec::new(), None); } let model = ide_build::maven::load_linked(root, maven); let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven); let excluded = unlinked.poms().map(PathBuf::from).collect(); let poms = unlinked.import_poms(&model).or_else(|| { gradle.linked_projects.as_ref().map(|_| { model .projects .iter() .filter(|p| !p.aggregated && !unlinked.contains(&p.pom)) .map(|p| p.pom.clone()) .collect() }) }); let build_files = poms.map(|poms| { let gradle = ide_build::gradle::load_linked(root, gradle).into_iter().filter_map(|g| { let dir = root.join(&g.dir); let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES); names.map(|n| dir.join(n)).find(|f| f.is_file()) }); let eclipse = if settings.languages.java.gradle.linked_projects.is_some() { independent_eclipse_projects(root) } else { Vec::new() }; poms.iter() .map(|pom| root.join(pom)) .chain(gradle) .chain(eclipse) .map(|f| ide_lsp::path_to_uri_string(&f)) .collect() }); (excluded, build_files) } /// Explicit Gradle imports must retain Eclipse projects without re-importing generated build metadata. fn independent_eclipse_projects(root: &Path) -> Vec { let managed = |dir: &Path| { std::iter::once("pom.xml") .chain(ide_build::gradle::SETTINGS_FILES.iter().copied()) .chain(ide_build::gradle::BUILD_FILES.iter().copied()) .any(|name| dir.join(name).is_file()) }; if managed(root) { return Vec::new(); } let walker = ignore::WalkBuilder::new(root) .standard_filters(false) .filter_entry(move |entry| { if !entry.file_type().is_some_and(|kind| kind.is_dir()) { return false; } if entry.depth() > 0 && (entry.file_name().to_string_lossy().starts_with('.') || matches!( entry.file_name().to_str(), Some("node_modules" | "target" | "build" | "dist" | "out" | "bin" | "vendor") ) || entry.path().parent().is_some_and(|parent| parent.join(".project").is_file())) { return false; } !managed(entry.path()) }) .build(); let mut projects: Vec<_> = walker.flatten().map(|entry| entry.path().join(".project")).filter(|path| path.is_file()).collect(); projects.sort(); projects } /// The detected roots with the last Gradle sync's source sets first: Gradle evaluated what /// static detection guesses. Folders outside the project keep their absolute path, which /// `normalize` turns into the same form the UI compares (`Users/me/svc/src/main/java`). fn with_gradle_roots( root: &Path, build_cache: &Path, settings: &ide_core::settings::GradleSettings, detected: &[SourceFolder], ) -> Vec { use ide_build::gradle::ReportedRoot; use ide_workspace::roots::RootKind; let mut out: Vec = Vec::new(); for (dir, kind) in ide_build::gradle_source_roots(root, build_cache, settings) { if !dir.is_dir() { continue; } let path = ide_workspace::roots::normalize(&dir.strip_prefix(root).unwrap_or(&dir).to_string_lossy()); let kind = match kind { ReportedRoot::Source => RootKind::Source, ReportedRoot::Test => RootKind::Test, ReportedRoot::Resources => RootKind::Resources, ReportedRoot::TestResources => RootKind::TestResources, ReportedRoot::Excluded => RootKind::Excluded, }; if !out.iter().any(|f| f.path == path) { out.push(SourceFolder { path, kind, detected_from: Some("Gradle".into()) }); } } out.extend(detected.iter().filter(|d| !out.iter().any(|o| o.path == d.path)).cloned().collect::>()); out } fn linked_roots(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> ProjectRoots { let unlinked = ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven); let detected = detected .iter() .filter(|folder| !folder.detected_from.as_ref().is_some_and(|pom| unlinked.contains(pom))) .cloned() .collect(); ProjectRoots::compute(detected, &settings.project_structure) } fn exclude_globs(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec { let mut globs = settings.file_excludes(); globs.extend(linked_roots(root, settings, detected).excluded_globs()); globs } pub(crate) fn project_key(root: &Path) -> String { // Stable FNV-1a directory name per project for caches. let s = root.to_string_lossy(); let mut h: u64 = 0xcbf29ce484222325; for b in s.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x100000001b3); } format!("{h:016x}") } struct LspObserver(Arc); impl DocumentObserver for LspObserver { fn opened(&self, info: &DocumentInfo) { // Language servers choke on huge files (IDEA disables code insight too); // changes to documents never opened on a server are ignored. if info.large { return; } match &info.uri { Some(uri) => self.0.did_open_virtual(info.id, uri, &info.language, info.version, &info.text), None => self.0.did_open(info.id, &info.path, &info.language, info.version, &info.text), } } fn changed( &self, doc: ide_core::DocId, _path: &Path, version: u32, changes: &[AppliedChange], full_text: &dyn Fn() -> String, ) { self.0.did_change(doc, version, changes, full_text); } fn saved(&self, doc: ide_core::DocId, _path: &Path, text: &str) { self.0.did_save(doc, text); } fn closed(&self, doc: ide_core::DocId, _path: &Path) { self.0.did_close(doc); } } /// Feeds document edits into the per-project syntax tree cache. struct SyntaxObserver(Arc); impl DocumentObserver for SyntaxObserver { fn opened(&self, info: &DocumentInfo) { if !info.large { self.0.open(info.id, &info.language, info.version); } } fn edited(&self, doc: ide_core::DocId, version: u32, edits: &[ByteEdit]) { self.0.edit(doc, version, edits); } fn closed(&self, doc: ide_core::DocId, _path: &Path) { self.0.close(doc); } } struct HistoryObserver(Arc); impl DocumentObserver for HistoryObserver { fn saved(&self, _doc: ide_core::DocId, path: &Path, text: &str) { if let Err(e) = self.0.record(path, text, "Saved") { tracing::warn!("local history: {e}"); } } } /// Uploads saved files: remote files' local copies, and automatic upload to the default server. struct DeployObserver { trusted: Arc, remote: Arc, settings: Arc, root: PathBuf, sink: SharedSink, runtime: tokio::runtime::Handle, } impl DocumentObserver for DeployObserver { fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { if !self.trusted.load(Ordering::Acquire) { return; } let (remote, settings, root, sink, path) = (self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf()); self.runtime.spawn(async move { if let Some(result) = remote.on_saved(&settings.effective(), &root, &path).await { notify_transfer(&sink, "Upload", &path, result); } }); } } fn delete_deployed( runtime: &tokio::runtime::Handle, remote: Arc, settings: Arc, root: PathBuf, removed: Vec, sink: SharedSink, ) { runtime.spawn(async move { if let Some(result) = remote.on_deleted(&settings.effective(), &root, &removed).await { let first = removed.first().cloned().unwrap_or_default(); notify_transfer(&sink, "Delete on server", &first, result); } }); } /// Reports a failed automatic transfer (successes stay quiet). fn notify_transfer(sink: &SharedSink, what: &str, path: &Path, result: IdeResult) { let body = match result { Ok(r) if r.errors.is_empty() => return, Ok(r) => r.errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect::>().join("\n"), Err(e) => e.message, }; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); sink.emit(IdeEvent::Notify { level: ide_core::event::NotifyLevel::Error, title: format!("{what} failed: {name}"), body: Some(body), }); } /// Settings › Version Control › Git › Path to Git executable (empty = auto-detect). pub fn apply_git_executable(settings: &ide_core::settings::Settings) { let exe = settings.vcs.git.executable.trim(); ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe))); } #[cfg(test)] mod maven_linkage_tests { #[test] fn explicit_gradle_links_preserve_independent_eclipse_projects() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); for dir in ["linked", "unlinked", "eclipse", "nested/eclipse", "unlinked/subproject", "maven", "build/generated"] { std::fs::create_dir_all(root.join(dir)).unwrap(); std::fs::write(root.join(dir).join(".project"), "").unwrap(); } for dir in ["linked", "unlinked"] { std::fs::write(root.join(dir).join("settings.gradle"), "").unwrap(); } std::fs::write(root.join("maven/pom.xml"), "").unwrap(); let mut settings = ide_core::settings::Settings::default(); settings.languages.java.gradle.linked_projects = Some(vec!["linked".into()]); let (_, files) = super::jdtls_maven_import(root, &settings); let files = files.unwrap(); let uri = |path: &str| ide_lsp::path_to_uri_string(&root.join(path)); assert!(files.contains(&uri("linked/settings.gradle"))); assert!(files.contains(&uri("maven/pom.xml"))); assert!(files.contains(&uri("eclipse/.project"))); assert!(files.contains(&uri("nested/eclipse/.project"))); assert!(!files.iter().any(|path| path.contains("unlinked") || path.contains("build/generated"))); assert!(!files.contains(&uri("linked/.project"))); assert!(!files.contains(&uri("maven/.project"))); settings.languages.java.gradle.linked_projects = Some(vec![]); let (_, files) = super::jdtls_maven_import(root, &settings); assert_eq!(files.unwrap(), [uri("maven/pom.xml"), uri("eclipse/.project"), uri("nested/eclipse/.project")]); } #[test] fn jdtls_imports_the_linked_modules_and_the_gradle_build_once_a_reactor_module_is_unlinked() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); for (dir, body) in [("", "xy"), ("x", ""), ("y", "")] { std::fs::create_dir_all(root.join(dir)).unwrap(); std::fs::write(root.join(dir).join("pom.xml"), format!("{body}")).unwrap(); } std::fs::write(root.join("settings.gradle"), "rootProject.name = 'g'").unwrap(); let mut settings = ide_core::settings::Settings::default(); assert_eq!(super::jdtls_maven_import(root, &settings), (Vec::new(), None)); settings.languages.java.maven.unlinked_projects.push("y/pom.xml".into()); let (excluded, files) = super::jdtls_maven_import(root, &settings); assert_eq!(excluded, vec![std::path::PathBuf::from("y/pom.xml")]); let uri = |f: &str| ide_lsp::path_to_uri_string(&root.join(f)); assert_eq!(files, Some(vec![uri("x/pom.xml"), uri("settings.gradle")])); // The whole reactor unlinked: the import exclusions are enough. settings.languages.java.maven.unlinked_projects = vec!["pom.xml".into()]; assert_eq!(super::jdtls_maven_import(root, &settings).1, None); } #[test] fn unlinked_roots_remain_plain_files() { let tmp = tempfile::tempdir().unwrap(); for name in ["a", "b"] { std::fs::create_dir_all(tmp.path().join(name).join("src/main/java")).unwrap(); std::fs::write(tmp.path().join(name).join("pom.xml"), "").unwrap(); } std::fs::create_dir_all(tmp.path().join("a/child/src/main/java")).unwrap(); std::fs::write(tmp.path().join("a/pom.xml"), "child") .unwrap(); std::fs::write(tmp.path().join("a/child/pom.xml"), "").unwrap(); let detected = ide_workspace::roots::detect(tmp.path()); let mut settings = ide_core::settings::Settings::default(); settings.languages.java.maven.unlinked_projects.push("a/pom.xml".into()); let excluded_poms = super::unlinked_maven_poms(tmp.path(), &settings); assert_eq!(excluded_poms, vec![std::path::PathBuf::from("a/child/pom.xml"), "a/pom.xml".into()]); let configs = ide_run::detect_excluding(tmp.path(), &excluded_poms); assert!(configs.iter().any(|c| c.kind == "maven")); assert!( configs .iter() .filter(|c| c.kind == "maven") .all(|c| { c.cwd.as_ref().is_some_and(|dir| dir.as_ref() == std::path::Path::new("b")) }) ); let mut server = serde_json::from_value::( serde_json::json!({"name": "jdtls", "command": "jdtls"}), ) .unwrap(); ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms, None); let options = serde_json::to_value(server.initialization_options).unwrap(); let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap(); assert!(exclusions.contains(&serde_json::json!(tmp.path().join("a").to_string_lossy().replace('\\', "/")))); assert!( exclusions.contains(&serde_json::json!(tmp.path().join("a/child").to_string_lossy().replace('\\', "/"))) ); let roots = super::linked_roots(tmp.path(), &settings, &detected); assert!(roots.roots.iter().any(|r| r.path == "b/src/main/java")); assert!(!roots.roots.iter().any(|r| r.path.starts_with("a/"))); assert!(tmp.path().join("a/pom.xml").is_file()); let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings); assert_eq!(model.maven.unwrap().projects.len(), 1); settings.languages.java.maven.unlinked_projects.push("b/pom.xml".into()); let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings); assert!(model.maven.unwrap().projects.is_empty()); } } fn vcs_path_changed(path: &Path) -> bool { !path.ends_with(".git/index") || !ide_vcs::is_own_index_write(path) } #[cfg(test)] mod gradle_roots_tests { use super::*; use ide_workspace::RootKind; #[test] fn gradle_sync_roots_win_and_reach_outside_the_project() { let tmp = tempfile::tempdir().unwrap(); let ws = dunce::canonicalize(tmp.path()).unwrap(); let (root, svc, cache) = (ws.join("p"), ws.join("svc"), ws.join("cache")); for d in [root.join("src/it/java"), svc.join("src/main/java"), svc.join("build")] { std::fs::create_dir_all(d).unwrap(); } let s = |p: PathBuf| p.to_string_lossy().into_owned(); let json = serde_json::json!({ "hash": "", "at_ms": 1.0, "error": null, "tasks": [], "projects": [ { "path": ":", "dir": s(root.clone()), "sourceSets": [ { "name": "integrationTest", "test": true, "src": [s(root.join("src/it/java"))], "res": [] } ] }, { "path": ":svc", "dir": s(svc.clone()), "buildDir": s(svc.join("build")), "sourceSets": [ { "name": "main", "test": false, "src": [s(svc.join("src/main/java")), s(svc.join("src/main/kotlin"))], "res": [] } ] }, ]}); std::fs::create_dir_all(&cache).unwrap(); std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap(); let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None }; let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None }; std::fs::write(root.join("build.gradle"), "").unwrap(); let out = with_gradle_roots(&root, &cache, &Default::default(), &[guessed, other.clone()]); let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p)); let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect(); assert_eq!( got, [ ("src/it/java".into(), RootKind::Test), (abs(svc.join("src/main/java")), RootKind::Source), (abs(svc.join("build")), RootKind::Excluded), ("web".into(), RootKind::Source), ] ); } } #[cfg(test)] mod index_event_tests { use super::vcs_path_changed; #[test] fn status_write_is_suppressed_but_external_index_and_refs_refresh() { let tmp = tempfile::tempdir().unwrap(); let dir = tmp.path(); let git = |args: &[&str]| { let output = std::process::Command::new("git").current_dir(dir).args(args).output().unwrap(); assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); }; git(&["init", "-q"]); let file = dir.join("file.txt"); std::fs::write(&file, "original\n").unwrap(); git(&["add", "file.txt"]); // Same contents, different stat data: status must refresh the index. std::fs::File::options() .write(true) .open(&file) .unwrap() .set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1)) .unwrap(); let index = dir.join(".git/index"); let before = std::fs::read(&index).unwrap(); let repo = ide_vcs::Repo::discover(dir).unwrap().unwrap(); repo.status().unwrap(); assert_ne!(before, std::fs::read(&index).unwrap(), "status must actually write the index"); assert!(!vcs_path_changed(&index)); assert!(!vcs_path_changed(&index), "duplicate notifications for the same identity"); for path in [".git/HEAD", ".git/refs/heads/main", ".git/packed-refs"] { assert!(vcs_path_changed(&dir.join(path))); } std::fs::write(&file, "external staged change\n").unwrap(); git(&["add", "file.txt"]); assert!(vcs_path_changed(&index)); assert!(vcs_path_changed(&index)); std::fs::remove_file(&index).unwrap(); assert!(vcs_path_changed(&index)); } } #[cfg(all(test, unix))] mod workspace_edit_tests { use super::*; use ide_core::{FileEdit, LanguageId, Pos, Range, TextEdit, WorkspaceEdit}; #[test] fn rename_via_symlink_updates_the_open_document_once() { let dir = tempfile::tempdir().unwrap(); let real = dir.path().join("main.cpp"); let alias = dir.path().join("alias.cpp"); std::fs::write(&real, "add();\n").unwrap(); std::os::unix::fs::symlink(&real, &alias).unwrap(); let documents = Documents::new(Arc::new(|_: IdeEvent| {}), Box::new(|_, _| LanguageId::new("cpp"))); let doc = documents.open(&alias).unwrap(); let changes = |path: PathBuf| FileEdit::Edit { path: path.into(), version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }], }; apply_workspace_edit( &documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() }, ) .unwrap(); assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n"); assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n"); } } #[cfg(test)] mod agent_worktree_tests { use super::agent_worktrees_dir; use std::path::{Path, PathBuf}; #[test] fn agent_worktrees_sit_next_to_the_project() { let fallback = || PathBuf::from("/home/me/.openidea/worktrees/key"); let project = Path::new("/work").join("acme-api"); assert_eq!(agent_worktrees_dir(&project, fallback), Path::new("/work").join("acme-api.worktrees")); assert_eq!(agent_worktrees_dir(Path::new("/"), fallback), fallback()); } } #[cfg(test)] mod composer_tests { use super::composer_changed; use std::path::{Path, PathBuf}; #[test] fn only_the_roots_own_composer_json_counts() { let root = Path::new("/work/app"); let changed = |paths: &[&str]| { let paths: Vec = paths.iter().map(|p| root.join(p)).collect(); composer_changed(root, paths.iter()) }; assert!(changed(&["src/a.php", "composer.json"])); assert!(!changed(&["composer.lock", "vendor/acme/lib/composer.json", "packages/b/composer.json"])); assert!(!changed(&[])); } }