дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Language servers never got workspace/didChangeWatchedFiles, so files changed on disk outside the editor (checkout, pull, another tool) stayed unknown to them; jdtls's incremental build skipped them and Java / Spring Boot launches ran their old classes (e.g. another module of a multi-module project). The project's file watcher now forwards each batch to the running servers: created, changed and removed files of a server's languages under its root. Refs #1015
1439 рядки
64 KiB
Rust
1439 рядки
64 KiB
Rust
//! 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<AtomicBool>,
|
||
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<LanguageRegistry>,
|
||
pub settings: Arc<SettingsStore>,
|
||
pub documents: Arc<Documents>,
|
||
pub history: Arc<LocalHistory>,
|
||
/// Undo stack of file move / rename refactorings.
|
||
pub moves: FileMoves,
|
||
pub index: Arc<ProjectIndex>,
|
||
pub lsp: Arc<LspManager>,
|
||
/// Git repositories: the one containing the project root and those nested below it.
|
||
pub repos: Arc<RwLock<Repos>>,
|
||
/// Incrementally maintained syntax trees of the open documents.
|
||
pub syntax: Arc<SyntaxCache>,
|
||
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
|
||
pub structure: Arc<StructureService>,
|
||
/// Roots auto-detected from build files; see [`Project::roots`].
|
||
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
|
||
/// 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<crate::ops::java::cache::Session>,
|
||
/// Database tool window: data sources and their connections.
|
||
pub db: Arc<ide_db::DbManager>,
|
||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||
pub http_history: Arc<ide_http::History>,
|
||
/// Coding agent tasks (Agents tool window).
|
||
pub agents: Arc<ide_agent::AgentManager>,
|
||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||
pub(crate) watcher: Watcher,
|
||
/// Watchers of the Project Structure content roots outside the project folder.
|
||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||
pub(crate) _scratch_watcher: Option<Watcher>,
|
||
/// 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<Arc<Project>> {
|
||
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);
|
||
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<PathBuf> = 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::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);
|
||
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());
|
||
}
|
||
}));
|
||
}
|
||
{
|
||
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<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||
index.update(&changed, &batch.removed);
|
||
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);
|
||
// Atomic saves by external tools surface as `created`.
|
||
documents.on_disk_changed(&changed);
|
||
// 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/<name>`. 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<PathBuf> = 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, 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,
|
||
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),
|
||
_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_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<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
|
||
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(),
|
||
));
|
||
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<Arc<Project>> {
|
||
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);
|
||
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(),
|
||
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()),
|
||
_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<LanguageRegistry>, root: Option<&Path>) -> Arc<Documents> {
|
||
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<Documents>,
|
||
history: &Arc<LocalHistory>,
|
||
) -> IdeResult<Watcher> {
|
||
let (documents, history) = (documents.clone(), history.clone());
|
||
Watcher::start_with(
|
||
dir,
|
||
Vec::new(),
|
||
sink.clone(),
|
||
Box::new(move |batch| {
|
||
let changed: Vec<PathBuf> = 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<Repos>,
|
||
settings: &SettingsStore,
|
||
sink: &SharedSink,
|
||
build_cache: &Path,
|
||
) {
|
||
let ignored = settings.effective().ignored_names();
|
||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||
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<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||
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")),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Runs `f` with the repository `path` (a file or folder) belongs to.
|
||
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||
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<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||
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<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||
self.with_repo_at(None, f)
|
||
}
|
||
|
||
pub(crate) fn with_disk_sync<T>(&self, f: impl FnOnce() -> IdeResult<T>) -> IdeResult<T> {
|
||
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<T>(
|
||
&self,
|
||
at: Option<&Path>,
|
||
operation: Option<&str>,
|
||
f: impl FnOnce(&Repo) -> IdeResult<T>,
|
||
) -> IdeResult<T> {
|
||
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 detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
|
||
linked_roots(&self.root, &self.settings.effective(), &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<String> {
|
||
let mut out: Vec<String> =
|
||
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 files skipped): 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<String> {
|
||
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<PathBuf> {
|
||
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.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<ide_vcs::TextEncoding> {
|
||
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_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<String> {
|
||
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.
|
||
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<PathBuf>) -> 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<PathBuf> {
|
||
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
|
||
return workspace.projects;
|
||
}
|
||
let mut out: Vec<PathBuf> = 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)) {
|
||
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.
|
||
fn content_root_watcher(
|
||
dir: &Path,
|
||
excludes: &[String],
|
||
sink: &SharedSink,
|
||
index: &Arc<ProjectIndex>,
|
||
documents: &Arc<Documents>,
|
||
history: &Arc<LocalHistory>,
|
||
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||
) -> Option<Watcher> {
|
||
let kotlin_bridge = kotlin_bridge.cloned();
|
||
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
|
||
Watcher::start(
|
||
dir,
|
||
excludes.to_vec(),
|
||
sink.clone(),
|
||
Box::new(move |batch| {
|
||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||
index.update(&changed, &batch.removed);
|
||
if let Some(bridge) = &kotlin_bridge {
|
||
bridge.files_changed(changed.iter().chain(&batch.removed));
|
||
}
|
||
documents.on_disk_changed(&changed);
|
||
record_external_changes(&history, &changed, &batch.removed);
|
||
}),
|
||
)
|
||
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
|
||
.ok()
|
||
}
|
||
|
||
/// Where agent tasks create their worktrees: next to the project, `<parent>/<name>.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<PathBuf> {
|
||
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<PathBuf>, Option<Vec<String>>) {
|
||
let maven = &settings.languages.java.maven;
|
||
if maven.unlinked_projects.is_empty() {
|
||
return (Vec::new(), None);
|
||
}
|
||
let model = ide_build::maven::load(root);
|
||
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
|
||
let excluded = unlinked.poms().map(PathBuf::from).collect();
|
||
let build_files = unlinked.import_poms(&model).map(|poms| {
|
||
let gradle = ide_build::gradle::load(root).and_then(|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())
|
||
});
|
||
poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect()
|
||
});
|
||
(excluded, build_files)
|
||
}
|
||
|
||
/// 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, detected: &[SourceFolder]) -> Vec<SourceFolder> {
|
||
use ide_build::gradle::ReportedRoot;
|
||
use ide_workspace::roots::RootKind;
|
||
let mut out: Vec<SourceFolder> = Vec::new();
|
||
for (dir, kind) in ide_build::gradle_source_roots(build_cache) {
|
||
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::<Vec<_>>());
|
||
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<String> {
|
||
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<LspManager>);
|
||
|
||
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<SyntaxCache>);
|
||
|
||
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<LocalHistory>);
|
||
|
||
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<AtomicBool>,
|
||
remote: Arc<ide_remote::RemoteService>,
|
||
settings: Arc<SettingsStore>,
|
||
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<ide_remote::RemoteService>,
|
||
settings: Arc<SettingsStore>,
|
||
root: PathBuf,
|
||
removed: Vec<PathBuf>,
|
||
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<ide_remote::TransferReport>) {
|
||
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::<Vec<_>>().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 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 [("", "<modules><module>x</module><module>y</module></modules>"), ("x", ""), ("y", "")] {
|
||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||
std::fs::write(root.join(dir).join("pom.xml"), format!("<project>{body}</project>")).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"), "<project/>").unwrap();
|
||
}
|
||
std::fs::create_dir_all(tmp.path().join("a/child/src/main/java")).unwrap();
|
||
std::fs::write(tmp.path().join("a/pom.xml"), "<project><modules><module>child</module></modules></project>")
|
||
.unwrap();
|
||
std::fs::write(tmp.path().join("a/child/pom.xml"), "<project/>").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::<ide_language::LanguageServerConfig>(
|
||
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 };
|
||
let out = with_gradle_roots(&root, &cache, &[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());
|
||
}
|
||
}
|