дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-12 20:56:00 +02:00
Порівняти коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
a97907a2bd | ||
|
|
405343b7b7 | ||
|
|
cc29842e6b | ||
|
|
de0f738bb2 | ||
|
|
d781df542a | ||
|
|
abfc4cbade | ||
|
|
646e5f8364 | ||
|
|
a3b48b78fd | ||
|
|
5e4fa861bb | ||
|
|
068ce8e721 | ||
|
|
e5cc1ed712 | ||
|
|
abe6285629 | ||
|
|
66a829c24c | ||
|
|
04f3f9434f | ||
|
|
4330034403 | ||
|
|
de8afe31d2 | ||
|
|
579e5eee9b | ||
|
|
099ede3478 | ||
|
|
fb269afdfa | ||
|
|
435b00d893 | ||
|
|
14fe542617 | ||
|
|
513762147a | ||
|
|
489f55f52c | ||
|
|
55fcbf281a | ||
|
|
f8aa003b47 | ||
|
|
e27b7f2502 | ||
|
|
bc00302ef8 | ||
|
|
2e044c4a2b | ||
|
|
b1959e8fb5 | ||
|
|
ce67c7e129 | ||
|
|
3cc94e340b | ||
|
|
51793e02c5 | ||
|
|
1e0ce64b0c |
No files matched your search
Створено
+13
@@ -4443,6 +4443,7 @@ dependencies = [
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
"ide-web",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
@@ -4601,6 +4602,7 @@ dependencies = [
|
||||
"tree-sitter-nix",
|
||||
"tree-sitter-ocaml",
|
||||
"tree-sitter-php",
|
||||
"tree-sitter-prisma-io",
|
||||
"tree-sitter-proto",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-ruby",
|
||||
@@ -4726,6 +4728,7 @@ name = "ide-remote"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -10437,6 +10440,16 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-prisma-io"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c35912d65a6adb07f781db46d843bf0464935e267da6ed192705516adafc7c24"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-proto"
|
||||
version = "0.6.0"
|
||||
|
||||
@@ -1343,6 +1343,15 @@ impl AgentManager {
|
||||
blocking(move || at.changes(&before)).await
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and, for a worktree task, the commits made since it
|
||||
/// started (newest first). Both are read against the same `HEAD`.
|
||||
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
|
||||
let task = self.task(id)?;
|
||||
let at = task.base();
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || at.grouped_changes(&before)).await
|
||||
}
|
||||
|
||||
/// Content of `path` (in the task's directory) when the task started.
|
||||
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
|
||||
let at = self.task(id)?.base();
|
||||
@@ -1353,9 +1362,14 @@ impl AgentManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
|
||||
/// were when it started (files the user had changed then, as the user left them).
|
||||
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as
|
||||
/// `view` compares them: as they were when it started, or as they are in `HEAD`.
|
||||
pub async fn rollback_changes(
|
||||
&self,
|
||||
id: &str,
|
||||
rel_paths: Option<Vec<String>>,
|
||||
view: AgentChangesView,
|
||||
) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
@@ -1366,8 +1380,10 @@ impl AgentManager {
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
let changes = at.changes(&before)?;
|
||||
let rev = at.rev()?;
|
||||
let (changes, rev) = match view {
|
||||
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
|
||||
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
|
||||
};
|
||||
let sources = match rev.as_deref() {
|
||||
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
|
||||
None => Default::default(),
|
||||
|
||||
@@ -484,7 +484,8 @@ pub struct NewAgentTask {
|
||||
pub base: Option<String>,
|
||||
}
|
||||
|
||||
/// A file the task changed, relative to [`AgentTask::base_commit`].
|
||||
/// A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
|
||||
/// grouped view.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChange {
|
||||
@@ -493,6 +494,44 @@ pub struct AgentChange {
|
||||
pub kind: ide_vcs::ChangeKind,
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
/// Path before a rename; the left side of its diff reads this path.
|
||||
pub old_path: Option<String>,
|
||||
}
|
||||
|
||||
/// A commit made while the task ran, with the files it changed against its first parent.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommit {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub summary: String,
|
||||
pub message: String,
|
||||
pub time_ms: f64,
|
||||
/// First parent, or `None` for a root commit.
|
||||
pub parent: Option<String>,
|
||||
pub files: Vec<AgentChange>,
|
||||
}
|
||||
|
||||
/// Which comparison a rollback of the task's changes uses.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentChangesView {
|
||||
/// Everything since the commit the task started from.
|
||||
#[default]
|
||||
SinceStart,
|
||||
/// The uncommitted files against `HEAD`; commits made since the start stay.
|
||||
Group,
|
||||
}
|
||||
|
||||
/// A task's uncommitted files and the commits made since it started.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChanges {
|
||||
/// Files changed against `HEAD`, less those changed before the task started and untouched since.
|
||||
pub uncommitted: Vec<AgentChange>,
|
||||
/// Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
|
||||
/// for tasks working in the project's own checkout.
|
||||
pub commits: Vec<AgentCommit>,
|
||||
}
|
||||
|
||||
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
|
||||
|
||||
@@ -2853,14 +2853,14 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
}
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
|
||||
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()]), AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(read("other.txt"), "base\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
|
||||
|
||||
// After a restart, from the saved copy.
|
||||
mgr.shutdown();
|
||||
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
|
||||
assert!(!dir.join("new.txt").exists());
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
@@ -2973,7 +2973,7 @@ async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
|
||||
|
||||
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
|
||||
assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}");
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
@@ -2996,7 +2996,7 @@ async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_ba
|
||||
repo.git(&["reset", "-q", "HEAD~1"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}");
|
||||
@@ -3023,7 +3023,7 @@ async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
|
||||
repo.git(&["checkout", "-q", "feature"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}");
|
||||
assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}");
|
||||
@@ -3048,7 +3048,7 @@ async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
|
||||
repo.write("a.txt", "agent\nagain\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it");
|
||||
assert!(!repo.dir.join("b.txt").exists(), "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
@@ -3069,7 +3069,7 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
|
||||
let listed = changed(&mgr, &task.id).await;
|
||||
assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, Some(listed)).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, Some(listed), AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}");
|
||||
assert!(!repo.dir.join("New.java").exists(), "{eol:?}");
|
||||
assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}");
|
||||
@@ -3082,6 +3082,27 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_group_view_rolls_back_only_uncommitted_files() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.commit("init");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
repo.commit("agent work");
|
||||
repo.write("a.txt", "agent\nagain\n");
|
||||
|
||||
let grouped = mgr.grouped_changes(&task.id).await.unwrap();
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"], "{eol:?}");
|
||||
assert!(grouped.commits.is_empty(), "{eol:?}: local tasks have no commits to group");
|
||||
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::Group).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to the start");
|
||||
assert!(mgr.grouped_changes(&task.id).await.unwrap().uncommitted.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
|
||||
for eol in EOLS {
|
||||
@@ -3092,7 +3113,7 @@ async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_c
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}");
|
||||
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert!(!repo.dir.join("new.txt").exists(), "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Task worktrees and the changes made in them.
|
||||
|
||||
use crate::model::AgentChange;
|
||||
use crate::model::{AgentChange, AgentChanges, AgentCommit};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use ide_vcs::{ChangeKind, LogQuery, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
@@ -35,6 +35,14 @@ pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> {
|
||||
Ok((info.head.unwrap_or_else(|| commit.clone()), commit))
|
||||
}
|
||||
|
||||
/// `HEAD`'s commit in `cwd`, `None` outside a repository or before its first commit.
|
||||
pub(crate) fn head_commit(cwd: &Path) -> IdeResult<Option<String>> {
|
||||
match discover(cwd)? {
|
||||
Some(repo) => Ok(repo.head()?.1),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `git worktree add -b agent/<slug> <dir>/<slug> <base>`.
|
||||
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
|
||||
let repo = repo_at(project_root)?;
|
||||
@@ -111,7 +119,14 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (added, removed) = stat(&f.path);
|
||||
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
|
||||
AgentChange {
|
||||
path: IpcPath(root.join(&f.path)),
|
||||
rel_path: f.path,
|
||||
kind: f.kind,
|
||||
added,
|
||||
removed,
|
||||
old_path: f.old_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
|
||||
@@ -140,6 +155,7 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
|
||||
kind: ChangeKind::Deleted,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
old_path: None,
|
||||
};
|
||||
Some((f.path, source))
|
||||
})
|
||||
@@ -148,7 +164,57 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
|
||||
|
||||
fn added(path: IpcPath, rel_path: String) -> AgentChange {
|
||||
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
|
||||
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
|
||||
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0, old_path: None }
|
||||
}
|
||||
|
||||
/// The commits from `base` (exclusive) to `head` (inclusive) in `cwd`, newest first, each
|
||||
/// with the files it changed against its first parent and their line counts.
|
||||
pub(crate) fn commits(cwd: &Path, base: &str, head: &str) -> IdeResult<Vec<AgentCommit>> {
|
||||
if base == head {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
|
||||
let root = repo.root().to_path_buf();
|
||||
let query = LogQuery { range: Some(format!("{base}..{head}")), ..LogQuery::default() };
|
||||
let mut out = Vec::new();
|
||||
for commit in repo.log(&query)? {
|
||||
let parent = commit.parents.first().cloned();
|
||||
// A root commit has no parent to count lines against; its files still show.
|
||||
let stats: std::collections::HashMap<String, (u32, u32)> = match &parent {
|
||||
Some(parent) => repo
|
||||
.diff_numstat(parent, &commit.id, &[])?
|
||||
.into_iter()
|
||||
.map(|s| (s.path, (s.added, s.removed)))
|
||||
.collect(),
|
||||
None => std::collections::HashMap::new(),
|
||||
};
|
||||
let mut files: Vec<AgentChange> = repo
|
||||
.commit_files(&commit.id)?
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (added, removed) = stats.get(&f.path).copied().unwrap_or((0, 0));
|
||||
AgentChange {
|
||||
path: IpcPath(root.join(&f.path)),
|
||||
rel_path: f.path,
|
||||
kind: f.kind,
|
||||
added,
|
||||
removed,
|
||||
old_path: f.old_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
out.push(AgentCommit {
|
||||
id: commit.id,
|
||||
short_id: commit.short_id,
|
||||
summary: commit.summary,
|
||||
message: commit.message,
|
||||
time_ms: commit.time_ms,
|
||||
parent,
|
||||
files,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
|
||||
@@ -243,6 +309,34 @@ impl TaskBase {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and, for a worktree task, the commits made since it
|
||||
/// started (newest first). Both read the same `HEAD`, so a commit landing between the two
|
||||
/// reads cannot put a file in both lists.
|
||||
pub(crate) fn grouped_changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<AgentChanges> {
|
||||
let cwd = self.cwd();
|
||||
let Some(repo) = discover(cwd)? else { return Ok(AgentChanges::default()) };
|
||||
let head = repo.head()?.1;
|
||||
let uncommitted = match &head {
|
||||
Some(head) => without_untouched(changes(cwd, head)?, before),
|
||||
None => {
|
||||
// No commits yet: every file is new, as in `changes` for a local task.
|
||||
let mut all: Vec<AgentChange> = repo
|
||||
.status()?
|
||||
.into_iter()
|
||||
.filter(|s| s.path.is_file())
|
||||
.map(|s| added(s.path, s.rel_path))
|
||||
.collect();
|
||||
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
without_untouched(all, before)
|
||||
}
|
||||
};
|
||||
let commits = match (self, &head) {
|
||||
(Self::Worktree { cwd, base }, Some(head)) => commits(cwd, base, head)?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(AgentChanges { uncommitted, commits })
|
||||
}
|
||||
|
||||
/// The revision the task's changed files go back to on rollback, and are compared with:
|
||||
/// the commit the task started from, or the current `HEAD` once the user committed on top
|
||||
/// of it, so those commits stay. `None` while the repository has no commits.
|
||||
@@ -598,6 +692,88 @@ mod tests {
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_since_the_base_are_listed_with_their_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
assert!(commits(&created.path, &created.base_commit, &created.base_commit).unwrap().is_empty());
|
||||
|
||||
std::fs::write(created.path.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
git(&created.path, &["add", "."]);
|
||||
git(&created.path, &["commit", "-q", "-m", "change a"]);
|
||||
std::fs::write(created.path.join("b.txt"), "b\n").unwrap();
|
||||
git(&created.path, &["add", "."]);
|
||||
git(&created.path, &["commit", "-q", "-m", "add b"]);
|
||||
|
||||
let head = head_of(&created.path).unwrap().1;
|
||||
let log = commits(&created.path, &created.base_commit, &head).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.summary.as_str()).collect::<Vec<_>>(), ["add b", "change a"]);
|
||||
assert_eq!(log[0].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["b.txt"]);
|
||||
assert_eq!((log[0].files[0].added, log[0].files[0].removed), (1, 0));
|
||||
assert_eq!(log[1].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
assert_eq!((log[1].files[0].added, log[1].files[0].removed), (1, 0));
|
||||
assert!(log.iter().all(|c| c.parent.is_some() && !c.message.is_empty()));
|
||||
|
||||
// Nothing is uncommitted after the commits; editing a committed file brings it back.
|
||||
let at = TaskBase::Worktree { cwd: created.path.clone(), base: created.base_commit.clone() };
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert!(grouped.uncommitted.is_empty());
|
||||
assert_eq!(grouped.commits.len(), 2);
|
||||
std::fs::write(created.path.join("a.txt"), "one\ntwo\nthree\n").unwrap();
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
assert_eq!(grouped.commits.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_in_a_commit_keeps_its_old_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
let base = head_of(&repo).unwrap().1;
|
||||
|
||||
std::fs::rename(repo.join("a.txt"), repo.join("b.txt")).unwrap();
|
||||
std::fs::write(repo.join("b.txt"), "one\ntwo\nthree\nfour\nFIVE\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "rename a"]);
|
||||
|
||||
let head = head_of(&repo).unwrap().1;
|
||||
let log = commits(&repo, &base, &head).unwrap();
|
||||
assert_eq!(log.len(), 1);
|
||||
assert_eq!(log[0].files.len(), 1);
|
||||
assert_eq!(log[0].files[0].rel_path, "b.txt");
|
||||
assert_eq!(log[0].files[0].old_path.as_deref(), Some("a.txt"));
|
||||
assert_eq!(log[0].files[0].kind, ChangeKind::Renamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_task_has_no_commits() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
|
||||
let at = TaskBase::Local { cwd: repo.clone(), start: None };
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert!(grouped.commits.is_empty());
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -204,7 +204,7 @@ pub fn parse_settings(text: &str) -> Settings {
|
||||
}
|
||||
|
||||
/// `a/b/../c` → `a/c`, without touching the filesystem.
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
pub(crate) fn normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
match c {
|
||||
|
||||
@@ -122,6 +122,14 @@ pub fn gradle_external_roots(
|
||||
gradle::external_roots(root, dirs.iter().map(String::as_str))
|
||||
}
|
||||
|
||||
/// Directories of Maven POMs linked from outside `root`, outermost only.
|
||||
/// Reads just those explicit paths (no POM parse and no directory walk).
|
||||
pub fn maven_external_roots(root: &Path, linked: &[String]) -> Vec<PathBuf> {
|
||||
let dirs = maven::external_pom_dirs(root, linked);
|
||||
let owned: Vec<String> = dirs.iter().map(|dir| dir.to_string_lossy().into_owned()).collect();
|
||||
gradle::external_roots(root, owned.iter().map(String::as_str))
|
||||
}
|
||||
|
||||
/// Source, resource and build folders of the last Gradle sync, absolute.
|
||||
pub fn gradle_source_roots(
|
||||
root: &Path,
|
||||
|
||||
@@ -106,7 +106,9 @@ pub struct MavenProject {
|
||||
pub profiles: Vec<String>,
|
||||
/// Java feature version the project compiles for (`maven.compiler.release`, `java.version`…).
|
||||
pub java_version: Option<u32>,
|
||||
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
|
||||
/// Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened
|
||||
/// project the search stops at its root; outside it, the search stops before
|
||||
/// that root so a linked project keeps its own wrapper.
|
||||
pub command: String,
|
||||
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
|
||||
pub options: Vec<String>,
|
||||
@@ -1032,10 +1034,6 @@ pub(crate) fn plugin(
|
||||
|
||||
const MAX_PROJECTS: usize = 1000;
|
||||
|
||||
fn rel_path(root: &Path, p: &Path) -> String {
|
||||
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// The POMs that are not modules of another one: the root `pom.xml`, else the
|
||||
/// shallowest `pom.xml` of each branch (a folder holding several builds). An
|
||||
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
|
||||
@@ -1049,18 +1047,29 @@ pub fn is_maven_project(root: &Path) -> bool {
|
||||
!top_level_poms(root).is_empty()
|
||||
}
|
||||
|
||||
/// The `mvnw` nearest to `dir`, looking up to `root`.
|
||||
/// The `mvnw` nearest to `dir`. Inside `root` the walk stops at `root`. Outside it,
|
||||
/// the walk stops before entering `root` or any ancestor of `root`: a sibling project
|
||||
/// keeps its own wrapper, and the search does not continue to the filesystem root.
|
||||
fn wrapper(root: &Path, dir: &Path) -> String {
|
||||
let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
|
||||
let mut cur = Some(dir);
|
||||
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||
let external = !dir.starts_with(&root);
|
||||
let mut cur = Some(dir.as_path());
|
||||
while let Some(d) = cur {
|
||||
if d.join(name).is_file() {
|
||||
return d.join(name).to_string_lossy().into_owned();
|
||||
}
|
||||
if d == root {
|
||||
if !external && d == root {
|
||||
break;
|
||||
}
|
||||
cur = d.parent();
|
||||
let Some(parent) = d.parent() else { break };
|
||||
// `parent` inside the opened project, or an ancestor of it (the common parent,
|
||||
// then the filesystem root). Stop before inspecting those.
|
||||
if external && (parent.starts_with(&root) || root.starts_with(parent)) {
|
||||
break;
|
||||
}
|
||||
cur = Some(parent);
|
||||
}
|
||||
if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() }
|
||||
}
|
||||
@@ -1104,24 +1113,118 @@ pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[St
|
||||
load_with_linked(root, local_repo, profiles, &[])
|
||||
}
|
||||
|
||||
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
|
||||
/// The `pom.xml` files `linked` names. Each entry is used as written (project-relative,
|
||||
/// `../sibling/pom.xml`, or absolute) and canonicalized; a path that is not an existing
|
||||
/// `pom.xml` is skipped. Nothing here lists a directory or walks parents looking for builds.
|
||||
fn linked_poms(root: &Path, linked: &[String]) -> Vec<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for pom in linked {
|
||||
let pom = normalized_pom(pom);
|
||||
if pom.is_empty() || pom.starts_with("../") || pom == ".." {
|
||||
continue;
|
||||
}
|
||||
let path = root.join(&pom);
|
||||
let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
|
||||
if path.is_file() && inside && !out.contains(&path) {
|
||||
let Some(path) = dunce::canonicalize(base.join(pom.trim())).ok() else { continue };
|
||||
if is_pom_file(&path) && !out.contains(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_pom_file(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" })
|
||||
}
|
||||
|
||||
/// Identity of `path` (both canonical) within `root`: project-relative inside it,
|
||||
/// otherwise the absolute path with forward slashes.
|
||||
fn project_rel(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// Canonical `pom.xml` paths named by `linked`, keyed by the spelling to store and to
|
||||
/// show so unlink removes the same entry settings hold. The first spelling wins.
|
||||
fn linked_identities(root: &Path, linked: &[String]) -> HashMap<PathBuf, String> {
|
||||
let mut out = HashMap::new();
|
||||
for entry in linked {
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = dunce::canonicalize(root.join(entry)).ok() else { continue };
|
||||
if is_pom_file(&path) {
|
||||
out.entry(path).or_insert_with(|| entry.replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Stable, deduplicated form of `poms` for `languages.java.maven.linkedProjects`.
|
||||
/// An existing `pom.xml` inside `root` is project-relative; one outside it is the
|
||||
/// canonical absolute path. Spellings of the same file (`../B/pom.xml`, an absolute
|
||||
/// path, a symlink) collapse to one entry. A path that exists and is not a `pom.xml`
|
||||
/// is dropped. A `pom.xml` that cannot be resolved yet is kept, so a link is not
|
||||
/// discarded while its volume is offline. No directory is scanned.
|
||||
pub fn normalize_linked_poms(root: &Path, poms: &[String]) -> Vec<String> {
|
||||
let Some(root) = dunce::canonicalize(root).ok() else {
|
||||
let mut out = Vec::new();
|
||||
for pom in poms {
|
||||
let pom = normalized_pom(pom);
|
||||
if !pom.is_empty() && pom_name_ok(&pom) && !out.contains(&pom) {
|
||||
out.push(pom);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for pom in poms {
|
||||
let pom = pom.trim();
|
||||
if pom.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = match dunce::canonicalize(root.join(pom)) {
|
||||
Ok(path) if is_pom_file(&path) => project_rel(&root, &path),
|
||||
Ok(_) => continue,
|
||||
Err(_) => {
|
||||
let fallback = normalized_pom(pom);
|
||||
let joined = root.join(pom);
|
||||
if !pom_name_ok(&fallback) || (joined.exists() && !is_pom_file(&joined)) {
|
||||
continue;
|
||||
}
|
||||
fallback
|
||||
}
|
||||
};
|
||||
if key.is_empty() || !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
out.push(key);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn pom_name_ok(path: &str) -> bool {
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" }
|
||||
}
|
||||
|
||||
/// Directories of linked `pom.xml` files that sit outside `root` (the opened project
|
||||
/// stays the root). Callers keep the outermost directory only. Modules a POM declares
|
||||
/// outside its own directory are not included: a linked file cannot widen the indexed
|
||||
/// tree to an arbitrary path.
|
||||
pub fn external_pom_dirs(root: &Path, linked: &[String]) -> Vec<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out = Vec::new();
|
||||
for pom in linked_poms(root, linked) {
|
||||
let Some(dir) = pom.parent() else { continue };
|
||||
if dir.starts_with(&base) || out.iter().any(|have| have == dir) {
|
||||
continue;
|
||||
}
|
||||
out.push(dir.to_path_buf());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `root` holds a Maven build or the settings link one inside it.
|
||||
pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
|
||||
is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
|
||||
@@ -1130,7 +1233,9 @@ pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -
|
||||
/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
|
||||
/// dependencies and properties are part of the model, those turned off are not) and the
|
||||
/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
|
||||
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
|
||||
/// top-level projects, including a `pom.xml` outside the opened root. A linked POM that
|
||||
/// turns out to be a module of the root reactor stays that module. The same canonical file
|
||||
/// is loaded once, so a cycle or a second spelling does not duplicate it or edit either POM.
|
||||
pub fn load_with_linked(
|
||||
root: &Path,
|
||||
local_repo: Option<PathBuf>,
|
||||
@@ -1139,7 +1244,10 @@ pub fn load_with_linked(
|
||||
) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo, profiles);
|
||||
let mut model = MavenModel::default();
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
// Canonical POM → its project, for a module listed again by a later aggregator.
|
||||
let mut seen: HashMap<PathBuf, Option<usize>> = HashMap::new();
|
||||
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let linked_ids = linked_identities(&canonical_root, linked);
|
||||
let mut tops = top_level_poms(root);
|
||||
for pom in linked_poms(root, linked) {
|
||||
if !tops.contains(&pom) {
|
||||
@@ -1147,17 +1255,31 @@ pub fn load_with_linked(
|
||||
}
|
||||
}
|
||||
// (pom, aggregated); the root reactor and its modules come before linked POMs.
|
||||
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
|
||||
while let Some((path, aggregated)) = stack.pop() {
|
||||
// (pom, listed by): a module carries the top-level project its chain started from and
|
||||
// whether the aggregator listing it lies inside the opened root.
|
||||
let mut stack: Vec<(PathBuf, Option<(usize, bool)>)> = tops.into_iter().rev().map(|p| (p, None)).collect();
|
||||
while let Some((path, listed_by)) = stack.pop() {
|
||||
let aggregated = listed_by.is_some();
|
||||
if model.projects.len() >= MAX_PROJECTS {
|
||||
break;
|
||||
}
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
if !seen.insert(canonical) {
|
||||
if let Some(known) = seen.get(&canonical) {
|
||||
// Found as a top-level POM first, then listed by an aggregator elsewhere
|
||||
// (`<module>../</module>` of `parent/pom.xml`): it is that aggregator's module.
|
||||
// Not when the chain leads back to where it started (a cycle), and not for an
|
||||
// aggregator outside the opened root: a linked sibling does not take it over.
|
||||
if let (Some(known), Some((origin, true))) = (*known, listed_by)
|
||||
&& known != origin
|
||||
{
|
||||
model.projects[known].aggregated = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.insert(canonical.clone(), None);
|
||||
let Some(pom) = resolver.pom(&path) else { continue };
|
||||
let dir = path.parent().unwrap_or(root).to_path_buf();
|
||||
let dir_canon = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
|
||||
let ctx = resolver.context(&path, 0);
|
||||
let props = resolver.properties_for(&pom, &ctx, &dir);
|
||||
let i = |s: &str| interpolate(s, &props);
|
||||
@@ -1168,7 +1290,7 @@ pub fn load_with_linked(
|
||||
}
|
||||
let mut modules: Vec<PathBuf> = Vec::new();
|
||||
for m in module_names {
|
||||
let mut mp = dir.join(i(&m));
|
||||
let mut mp = crate::gradle::normalize(&dir.join(i(&m)));
|
||||
if mp.is_dir() || mp.extension().is_none_or(|e| e != "xml") {
|
||||
mp.push("pom.xml");
|
||||
}
|
||||
@@ -1232,28 +1354,39 @@ pub fn load_with_linked(
|
||||
}
|
||||
}
|
||||
let artifact_id = i(&pom.artifact_id);
|
||||
let module_ids: Vec<String> = modules
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let canonical = dunce::canonicalize(m).unwrap_or_else(|_| m.clone());
|
||||
linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical))
|
||||
})
|
||||
.collect();
|
||||
model.projects.push(MavenProject {
|
||||
project_root: None,
|
||||
pom: rel_path(root, &path),
|
||||
dir: rel_path(root, &dir),
|
||||
pom: linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical)),
|
||||
dir: project_rel(&canonical_root, &dir_canon),
|
||||
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
|
||||
version: i(props.get("project.version").map(String::as_str).unwrap_or_default()),
|
||||
display_name: pom.name.as_deref().map(i).filter(|n| !n.is_empty()).unwrap_or_else(|| artifact_id.clone()),
|
||||
artifact_id,
|
||||
packaging,
|
||||
parent: pom.parent.as_ref().map(|p| format!("{}:{}:{}", p.group_id, p.artifact_id, i(&p.version))),
|
||||
modules: modules.iter().map(|m| rel_path(root, m)).collect(),
|
||||
modules: module_ids,
|
||||
aggregated,
|
||||
plugins,
|
||||
dependencies,
|
||||
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
|
||||
java_version: jv,
|
||||
command: wrapper(root, &dir),
|
||||
command: wrapper(&canonical_root, &dir_canon),
|
||||
options: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
});
|
||||
let index = model.projects.len() - 1;
|
||||
seen.insert(canonical, Some(index));
|
||||
let origin = listed_by.map_or(index, |(origin, _)| origin);
|
||||
let inside = dir_canon.starts_with(&canonical_root);
|
||||
for m in modules.into_iter().rev() {
|
||||
stack.push((m, true));
|
||||
stack.push((m, Some((origin, inside))));
|
||||
}
|
||||
}
|
||||
model
|
||||
@@ -1341,17 +1474,27 @@ impl UnlinkedProjects {
|
||||
|
||||
fn normalized_pom(pom: &str) -> String {
|
||||
let pom = pom.replace('\\', "/");
|
||||
let mut parts = Vec::new();
|
||||
let unc = pom.starts_with("//");
|
||||
let absolute = pom.starts_with('/');
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in pom.split('/') {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
// Keep a drive letter (`C:`) and a leading `..` that has nothing to climb.
|
||||
".." if parts.last().is_some_and(|p| *p != ".." && !p.ends_with(':')) => {
|
||||
parts.pop();
|
||||
}
|
||||
part => parts.push(part),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
let joined = parts.join("/");
|
||||
if unc {
|
||||
format!("//{joined}")
|
||||
} else if absolute {
|
||||
format!("/{joined}")
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
|
||||
/// Every POM of the model (for change detection).
|
||||
@@ -2147,6 +2290,169 @@ mod tests {
|
||||
assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
|
||||
}
|
||||
|
||||
/// A sibling Maven project is linked beside the opened root. Its reactor, including a
|
||||
/// module that points back at the opened project, is read once. Neither POM is rewritten,
|
||||
/// and only the linked project's directory is an extra content root — not the common
|
||||
/// parent and not a module that lives outside that directory.
|
||||
#[test]
|
||||
fn external_sibling_keeps_its_directory_and_does_not_edit_poms() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let a = base.join("A");
|
||||
let b = base.join("B");
|
||||
let c = base.join("C");
|
||||
std::fs::create_dir_all(a.join("core")).unwrap();
|
||||
std::fs::create_dir_all(b.join("mod")).unwrap();
|
||||
std::fs::create_dir_all(&c).unwrap();
|
||||
let a_pom = "<project><groupId>demo</groupId><artifactId>a</artifactId><version>1</version><packaging>pom</packaging><modules><module>core</module></modules></project>";
|
||||
let b_pom = "<project><groupId>demo</groupId><artifactId>b</artifactId><version>1</version><packaging>pom</packaging><modules><module>mod</module><module>../A</module><module>../C</module><module>.</module></modules></project>";
|
||||
let c_pom = "<project><groupId>demo</groupId><artifactId>c</artifactId><version>1</version><modules><module>../B</module></modules></project>";
|
||||
std::fs::write(a.join("pom.xml"), a_pom).unwrap();
|
||||
std::fs::write(a.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
|
||||
std::fs::write(b.join("pom.xml"), b_pom).unwrap();
|
||||
std::fs::write(b.join("mod/pom.xml"), "<project><artifactId>bmod</artifactId></project>").unwrap();
|
||||
std::fs::write(c.join("pom.xml"), c_pom).unwrap();
|
||||
let wrapper = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
|
||||
std::fs::write(a.join(wrapper), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(b.join(wrapper), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(base.join(wrapper), "#!/bin/sh\n").unwrap();
|
||||
|
||||
let discovered = load_with_linked(&a, None, &[], &[]);
|
||||
assert_eq!(discovered.projects.iter().map(|p| p.artifact_id.as_str()).collect::<Vec<_>>(), ["a", "core"]);
|
||||
|
||||
let stored = vec!["../B/pom.xml".into()];
|
||||
let linked = load_with_linked(&a, None, &[], &stored);
|
||||
let b_project = linked.projects.iter().find(|p| p.artifact_id == "b").unwrap();
|
||||
assert!(!b_project.aggregated);
|
||||
assert_eq!(b_project.pom, "../B/pom.xml");
|
||||
assert_eq!(dunce::canonicalize(a.join(&b_project.dir)).unwrap(), b);
|
||||
assert_eq!(dunce::canonicalize(&b_project.command).unwrap(), dunce::canonicalize(b.join(wrapper)).unwrap());
|
||||
let a_project = linked.projects.iter().find(|p| p.artifact_id == "a").unwrap();
|
||||
assert!(!a_project.aggregated);
|
||||
assert_eq!(a_project.modules, ["core/pom.xml"]);
|
||||
assert_eq!(dunce::canonicalize(&a_project.command).unwrap(), dunce::canonicalize(a.join(wrapper)).unwrap());
|
||||
assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "a").count(), 1);
|
||||
assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "b").count(), 1);
|
||||
assert!(linked.projects.iter().any(|p| p.artifact_id == "c" && p.aggregated));
|
||||
assert!(linked.projects.iter().any(|p| p.artifact_id == "bmod" && p.aggregated));
|
||||
let again = load_with_linked(&a, None, &[], &stored);
|
||||
assert_eq!(
|
||||
again.projects.iter().map(|p| &p.pom).collect::<Vec<_>>(),
|
||||
linked.projects.iter().map(|p| &p.pom).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let absolute = b.join("pom.xml").to_string_lossy().replace('\\', "/");
|
||||
let via_a = a.join("../B/pom.xml").to_string_lossy().replace('\\', "/");
|
||||
let normalized = normalize_linked_poms(
|
||||
&a,
|
||||
&[
|
||||
"../B/pom.xml".into(),
|
||||
absolute,
|
||||
via_a,
|
||||
a.join("pom.xml").to_string_lossy().into_owned(),
|
||||
b.to_string_lossy().into_owned(),
|
||||
"stub/pom.xml".into(),
|
||||
"notes.xml".into(),
|
||||
],
|
||||
);
|
||||
assert_eq!(normalized.len(), 3, "{normalized:?}");
|
||||
assert_eq!(normalized[0], dunce::canonicalize(b.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/"));
|
||||
assert_eq!(normalized[1], "pom.xml");
|
||||
assert_eq!(normalized[2], "stub/pom.xml");
|
||||
assert_eq!(crate::maven_external_roots(&a, &normalized), vec![b.clone()]);
|
||||
assert_eq!(crate::maven_external_roots(&a, &stored), vec![b.clone()]);
|
||||
|
||||
let removed = load_with_linked(&a, None, &[], &[]);
|
||||
assert!(removed.projects.iter().all(|p| p.artifact_id != "b"));
|
||||
assert_eq!(std::fs::read_to_string(a.join("pom.xml")).unwrap(), a_pom);
|
||||
assert_eq!(std::fs::read_to_string(b.join("pom.xml")).unwrap(), b_pom);
|
||||
assert_eq!(std::fs::read_to_string(c.join("pom.xml")).unwrap(), c_pom);
|
||||
assert!(b.join("mod/pom.xml").is_file());
|
||||
}
|
||||
|
||||
/// The launcher recorded on an external project is that project's wrapper, and
|
||||
/// running it uses the external directory as the working directory.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_project_wrapper_runs_in_its_own_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let a = base.join("A");
|
||||
let b = base.join("B");
|
||||
std::fs::create_dir_all(b.join("src/main/java")).unwrap();
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::write(a.join("pom.xml"), "<project><artifactId>a</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
b.join("pom.xml"),
|
||||
"<project><artifactId>b</artifactId><build><sourceDirectory>src/main/java</sourceDirectory></build></project>",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(b.join("src/main/java/B.java"), "class B {}").unwrap();
|
||||
let script = b.join("mvnw");
|
||||
std::fs::write(&script, "#!/bin/sh\npwd > \"$1\"\nprintf 'BUILD SUCCESS\\n'\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let model = load_with_linked(&a, None, &[], &["../B/pom.xml".into()]);
|
||||
let project = model.projects.iter().find(|p| p.artifact_id == "b").unwrap();
|
||||
let cwd = a.join(&project.dir);
|
||||
assert_eq!(dunce::canonicalize(&cwd).unwrap(), b);
|
||||
let marker = tmp.path().join("cwd");
|
||||
let out = crate::process::run(
|
||||
&project.command,
|
||||
&[marker.to_string_lossy().into_owned()],
|
||||
&cwd,
|
||||
&std::collections::BTreeMap::new(),
|
||||
&ide_core::CancellationToken::new(),
|
||||
|_, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.success, "{}", out.text);
|
||||
let recorded = dunce::canonicalize(std::fs::read_to_string(&marker).unwrap().trim()).unwrap();
|
||||
assert_eq!(recorded, b);
|
||||
}
|
||||
|
||||
/// #2877: an aggregator in a subfolder lists the root POM as `../`; linked, it holds every
|
||||
/// module, the root one included, under the same POM paths the projects have.
|
||||
#[test]
|
||||
fn an_aggregator_in_a_subfolder_holds_the_root_pom() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let child = |id: &str, rel: &str| {
|
||||
format!(
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><relativePath>{rel}</relativePath></parent><artifactId>{id}</artifactId></project>"
|
||||
)
|
||||
};
|
||||
write(root, "pom.xml", &child("app", "parent/pom.xml"));
|
||||
write(
|
||||
root,
|
||||
"parent/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>../</module><module>../infra</module></modules></project>",
|
||||
);
|
||||
write(root, "infra/pom.xml", &child("infra", "../parent/pom.xml"));
|
||||
|
||||
let model = load_with_linked(root, None, &[], &["parent/pom.xml".into()]);
|
||||
let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect();
|
||||
assert_eq!(tops, ["parent"]);
|
||||
let parent = model.projects.iter().find(|p| p.artifact_id == "parent").unwrap();
|
||||
assert_eq!(parent.modules, ["pom.xml", "infra/pom.xml"]);
|
||||
for pom in &parent.modules {
|
||||
assert!(model.projects.iter().any(|p| &p.pom == pom), "{pom}");
|
||||
}
|
||||
|
||||
// A cycle (the root lists the aggregator, which lists the root) keeps the root on top.
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>parent</module></modules></project>",
|
||||
);
|
||||
let model = load_with_linked(root, None, &[], &[]);
|
||||
let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect();
|
||||
assert_eq!(tops, ["app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_profiles_bring_their_modules_and_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -78,6 +78,10 @@ pub struct EditorAppearanceSettings {
|
||||
/// Right margin and visual guides from Code Style.
|
||||
pub hard_wrap_guides: bool,
|
||||
pub intention_bulb: bool,
|
||||
/// Draw doc comments (Javadoc, KDoc, JSDoc, rustdoc) as rendered documentation
|
||||
/// instead of their source, as IDEA's option of the same name. View › Active
|
||||
/// Editor › Render Documentation Comments overrides this for one editor.
|
||||
pub render_doc_comments: bool,
|
||||
/// Distraction Free Mode: width of the centred text column, in characters.
|
||||
pub distraction_free_width: u32,
|
||||
}
|
||||
@@ -100,6 +104,7 @@ impl Default for EditorAppearanceSettings {
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
render_doc_comments: false,
|
||||
distraction_free_width: 80,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,7 +1182,9 @@ pub struct MavenSettings {
|
||||
pub runner_jre: String,
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<String>,
|
||||
/// Project-relative POMs outside the root reactor added as independent top-level projects.
|
||||
/// POMs added as independent top-level projects. Inside the opened project the path is
|
||||
/// project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking
|
||||
/// does not edit either POM.
|
||||
pub linked_projects: Vec<String>,
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
|
||||
@@ -192,6 +192,33 @@ pub struct IssueLink {
|
||||
pub link: String,
|
||||
}
|
||||
|
||||
/// Per-level colours for console log lines: a line whose level is detected
|
||||
/// (`ERROR`, `WARN`, …) takes the level's colour; ANSI colours the program
|
||||
/// prints itself win over it. Values are CSS colours, usually scheme tokens.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LogLevelColors {
|
||||
pub enabled: bool,
|
||||
pub error: String,
|
||||
pub warn: String,
|
||||
pub info: String,
|
||||
pub debug: String,
|
||||
pub trace: String,
|
||||
}
|
||||
|
||||
impl Default for LogLevelColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
error: "var(--ansi-red)".into(),
|
||||
warn: "var(--ansi-yellow)".into(),
|
||||
info: "var(--ansi-green)".into(),
|
||||
debug: "var(--ansi-cyan)".into(),
|
||||
trace: "var(--ansi-magenta)".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Console.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -203,6 +230,8 @@ pub struct ConsoleSettings {
|
||||
pub encoding: String,
|
||||
/// Consecutive lines containing any of these are folded.
|
||||
pub fold_patterns: Vec<String>,
|
||||
/// Log lines take the colour of their level (Run/Debug consoles).
|
||||
pub log_colors: LogLevelColors,
|
||||
}
|
||||
|
||||
impl Default for ConsoleSettings {
|
||||
@@ -219,6 +248,7 @@ impl Default for ConsoleSettings {
|
||||
"at node:internal/".into(),
|
||||
"<frozen importlib._bootstrap".into(),
|
||||
],
|
||||
log_colors: LogLevelColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,16 @@ fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiberius requires TLS when a TLS backend is enabled. `trust_cert` only skips
|
||||
/// certificate checks, so Disable has to turn encryption off.
|
||||
fn apply_sqlserver_ssl(config: &mut tiberius::Config, ssl: DbSslMode) {
|
||||
match ssl {
|
||||
DbSslMode::Disable => config.encryption(tiberius::EncryptionLevel::NotSupported),
|
||||
DbSslMode::Prefer => config.trust_cert(),
|
||||
DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull => {}
|
||||
}
|
||||
}
|
||||
|
||||
const DAYS_1900_01_01: i64 = 693_595;
|
||||
|
||||
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
|
||||
@@ -876,9 +886,7 @@ impl Pool {
|
||||
if !source.database.is_empty() {
|
||||
config.database(&source.database);
|
||||
}
|
||||
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
|
||||
config.trust_cert();
|
||||
}
|
||||
apply_sqlserver_ssl(&mut config, source.ssl);
|
||||
let manager = MssqlConnectionManager(config);
|
||||
let pool = bb8::Pool::builder()
|
||||
.max_size(MAX)
|
||||
@@ -1331,6 +1339,33 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::source::{DbOptions, DbProperty};
|
||||
|
||||
fn sqlserver_debug(ssl: DbSslMode) -> String {
|
||||
let mut config = tiberius::Config::new();
|
||||
apply_sqlserver_ssl(&mut config, ssl);
|
||||
format!("{config:?}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_disable_does_not_require_tls() {
|
||||
let debug = sqlserver_debug(DbSslMode::Disable);
|
||||
assert!(debug.contains("encryption: NotSupported"), "{debug}");
|
||||
assert!(debug.contains("bypass: false"), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_prefer_trusts_the_server_certificate() {
|
||||
let debug = sqlserver_debug(DbSslMode::Prefer);
|
||||
assert!(debug.contains("encryption: Required"), "{debug}");
|
||||
assert!(debug.contains("bypass: true"), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_require_keeps_validated_tls() {
|
||||
let debug = sqlserver_debug(DbSslMode::Require);
|
||||
assert!(debug.contains("encryption: Required"), "{debug}");
|
||||
assert!(debug.contains("bypass: false"), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_sqlserver_connections_are_broken() {
|
||||
let manager = MssqlConnectionManager(tiberius::Config::new());
|
||||
|
||||
@@ -46,6 +46,7 @@ globset.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-web.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-notebook.workspace = true
|
||||
|
||||
@@ -84,6 +84,9 @@ pub struct Host {
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
/// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`).
|
||||
pub environment: environment::Environment,
|
||||
/// The built-in web server (Open In › Browser, the editor's browser bar). On a remote host the
|
||||
/// client forwards its ports.
|
||||
pub web: ide_web::WebServer,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
/// The proxy password when it could not be saved (no credential store).
|
||||
@@ -140,6 +143,7 @@ impl Host {
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
web: ide_web::WebServer::new(),
|
||||
global_settings,
|
||||
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
|
||||
data_dir.join("cache/dependency-versions"),
|
||||
|
||||
@@ -5,9 +5,9 @@ use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
|
||||
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
|
||||
NewAgentTask,
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
|
||||
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
|
||||
McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -304,16 +304,28 @@ pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<A
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and the commits made since it started.
|
||||
pub async fn grouped_changes(host: &Host, window: &str, task: String) -> IdeResult<AgentChanges> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.grouped_changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
|
||||
pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
|
||||
pub async fn rollback_changes(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
task: String,
|
||||
paths: Option<Vec<String>>,
|
||||
view: Option<AgentChangesView>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback_changes(&task, paths).await
|
||||
host.project(window)?.agents.rollback_changes(&task, paths, view.unwrap_or_default()).await
|
||||
}
|
||||
|
||||
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
@@ -476,9 +488,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await },
|
||||
);
|
||||
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_grouped_changes", |c| async move { grouped_changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
|
||||
d.add("agent_rollback_changes", |c| async move {
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?, c.arg("view")?).await
|
||||
});
|
||||
d.add("agent_accept_changes", |c| async move {
|
||||
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod syntax;
|
||||
pub mod term;
|
||||
pub mod tools;
|
||||
pub mod vcs;
|
||||
pub mod web;
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
|
||||
@@ -164,10 +164,21 @@ pub fn set_settings(
|
||||
// A LightEdit window has no project layer: its values are global.
|
||||
let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light);
|
||||
let is_proxy = key == "proxy" || key.starts_with("proxy.");
|
||||
let Some(value) = host.take_proxy_password(key, value)? else {
|
||||
let Some(mut value) = host.take_proxy_password(key, value)? else {
|
||||
host.apply_proxy();
|
||||
return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None });
|
||||
};
|
||||
// One canonical spelling per linked POM, including a project outside the opened root.
|
||||
if project_scope
|
||||
&& key == "languages.java.maven.linkedProjects"
|
||||
&& let Some(project) = current.as_ref()
|
||||
&& let Some(list) = value.as_array()
|
||||
{
|
||||
let poms: Vec<String> = list.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect();
|
||||
if poms.len() == list.len() {
|
||||
value = serde_json::json!(ide_build::maven::normalize_linked_poms(&project.root, &poms));
|
||||
}
|
||||
}
|
||||
let settings = match ¤t {
|
||||
Some(p) if project_scope => p.settings.set(scope, key, value)?,
|
||||
Some(p) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::Host;
|
||||
use ide_core::remote_settings::RunTargetKind;
|
||||
use ide_core::{IdeError, IdeResult, RunId};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Detected configs followed by user-defined ones (user ids win on conflict).
|
||||
@@ -138,11 +139,24 @@ pub fn save_configs(
|
||||
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
|
||||
}
|
||||
|
||||
/// The project `config` runs in (a workspace member, by its working directory) and its settings.
|
||||
/// The project `config` runs in (a workspace member, by its working directory) and its
|
||||
/// settings, with the primary project as the inherited fallback: a member with no Python
|
||||
/// of its own runs on the primary project's interpreter (see `apply_to_run_configs_in`).
|
||||
fn settings_for(
|
||||
host: &Host,
|
||||
project: &crate::project::Project,
|
||||
config: &mut RunConfig,
|
||||
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings, Option<std::path::PathBuf>)> {
|
||||
let (root, settings) = settings_only(host, project, config)?;
|
||||
let fallback = (root != project.root).then_some(project.root.clone());
|
||||
Ok((root, settings, fallback))
|
||||
}
|
||||
|
||||
/// [`settings_for`] without the fallback, for callers that only rebase or read settings.
|
||||
fn settings_only(
|
||||
host: &Host,
|
||||
project: &crate::project::Project,
|
||||
config: &mut RunConfig,
|
||||
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
|
||||
let owner = ide_run::scope::owner(&project.root, config);
|
||||
@@ -180,8 +194,8 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
|
||||
if c.kind == "gradle" {
|
||||
return Ok(c);
|
||||
}
|
||||
let (root, settings) = settings_for(host, &project, &mut c)?;
|
||||
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
|
||||
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
|
||||
Ok(ide_sdk::apply::without_derived_in(&c, &root, fallback.as_deref(), &settings))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -194,8 +208,13 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
if c.kind != "gradle" {
|
||||
let (root, settings) = settings_for(host, &project, &mut c)?;
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
|
||||
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
|
||||
ide_sdk::apply::apply_to_run_configs_in(
|
||||
std::slice::from_mut(&mut c),
|
||||
&root,
|
||||
fallback.as_deref(),
|
||||
&settings,
|
||||
);
|
||||
}
|
||||
Ok(c)
|
||||
})
|
||||
@@ -329,7 +348,7 @@ async fn start_inner(
|
||||
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
|
||||
}
|
||||
let mut config = config;
|
||||
let (root, settings) = settings_for(host, &project, &mut config)?;
|
||||
let (root, settings, fallback) = settings_for(host, &project, &mut config)?;
|
||||
let root = &root;
|
||||
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
|
||||
// Maven re-applies too: a config listed before a settings change (daemon toggle,
|
||||
@@ -338,17 +357,38 @@ async fn start_inner(
|
||||
if config.kind == "maven" {
|
||||
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
|
||||
} else if matches!(config.kind.as_str(), "gradle" | "tomcat" | "junit") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
ide_sdk::apply::apply_to_run_configs_in(
|
||||
std::slice::from_mut(&mut config),
|
||||
root,
|
||||
fallback.as_deref(),
|
||||
&settings,
|
||||
);
|
||||
} else {
|
||||
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
|
||||
}
|
||||
if config.kind == "go" {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
ide_sdk::apply::apply_to_run_configs_in(
|
||||
std::slice::from_mut(&mut config),
|
||||
root,
|
||||
fallback.as_deref(),
|
||||
&settings,
|
||||
);
|
||||
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
|
||||
}
|
||||
if config.command == ide_sdk::godot::COMMAND {
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
// A Python configuration pointing at a missing interpreter fails in the depth of
|
||||
// process spawn otherwise; name the cause and the fix like PyCharm's "Invalid SDK".
|
||||
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
|
||||
&& !Path::new(&config.command).is_file()
|
||||
&& Path::new(&config.command).components().count() > 1
|
||||
{
|
||||
return Err(IdeError::invalid(format!(
|
||||
"The configured Python interpreter does not exist: {}\nSelect a working interpreter in Settings › Languages & Frameworks › Python Interpreter.",
|
||||
config.command
|
||||
)));
|
||||
}
|
||||
if config.kind == "tomcat" {
|
||||
if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("This Tomcat configuration is already running"));
|
||||
|
||||
@@ -69,7 +69,14 @@ pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) ->
|
||||
}
|
||||
None => (project.root.clone(), project.settings.effective()),
|
||||
};
|
||||
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
|
||||
// A member with no Python of its own falls back to the primary project's
|
||||
// (the same chain run configurations use), so every answer names one interpreter.
|
||||
let interpreter = ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).or_else(|| {
|
||||
(root != project.root)
|
||||
.then(|| ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()))
|
||||
.flatten()
|
||||
});
|
||||
Ok(interpreter.map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
|
||||
|
||||
@@ -161,6 +161,23 @@ pub async fn jdk_download(
|
||||
Ok(sdk)
|
||||
}
|
||||
|
||||
/// The Java version the jdtls this window launches needs; `None` while it isn't
|
||||
/// installed or doesn't tell.
|
||||
pub async fn jdtls_required_java(host: &Host, window: &str) -> IdeResult<Option<u32>> {
|
||||
let settings = match host.project(window) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
let Some(mut cfg) =
|
||||
languages(host, window).into_iter().flat_map(|l| l.language_servers).find(|s| s.name == "jdtls")
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
ide_sdk::apply::server_launch_overrides(&mut cfg, &settings);
|
||||
ide_sdk::apply::server_protocol_overrides(&mut cfg, &settings);
|
||||
tokio::task::spawn_blocking(move || ide_sdk::java::jdtls_required_java(&cfg)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("tools_list", |c| async move { tools_list(&c.host, &c.window).await });
|
||||
@@ -173,6 +190,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("jdk_download_location", |c| async move { jdk_download_location(&c.host, &c.window) });
|
||||
d.add("jdtls_required_java", |c| async move { jdtls_required_java(&c.host, &c.window).await });
|
||||
d.add("jdk_download", |c| async move {
|
||||
jdk_download(
|
||||
&c.host,
|
||||
|
||||
@@ -373,6 +373,25 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
|
||||
.await
|
||||
}
|
||||
|
||||
/// The file's git mode at `rev` (`100644`, `100755`, `120000`…); `"WT"` reads the
|
||||
/// worktree's. `None` when the path is absent on that side. The diff viewer shows
|
||||
/// "File mode changed" when the texts compare equal but the modes differ.
|
||||
pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window).ok();
|
||||
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
|
||||
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
|
||||
});
|
||||
blocking(move || {
|
||||
if let Some(project) = project {
|
||||
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
|
||||
}
|
||||
external
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
.file_mode_at(&path, rev.as_deref())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_blame(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -1392,6 +1411,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_file_mode", |c| async move { vcs_file_mode(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_blame", |c| async move {
|
||||
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//! The built-in web server's URLs.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use std::path::Path;
|
||||
|
||||
/// The URL serving `file`, under the window's project when it holds the file.
|
||||
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
host.web.url_for(root.as_deref(), file).await
|
||||
}
|
||||
@@ -142,6 +142,10 @@ impl Host {
|
||||
crate::plugins::injections::configure_lsp(&lsp);
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
lsp.set_jdtls_import_roots(ide_build::maven_external_roots(
|
||||
&root,
|
||||
&settings.effective().languages.java.maven.linked_projects,
|
||||
));
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
@@ -1120,6 +1124,10 @@ impl Project {
|
||||
}
|
||||
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
|
||||
self.lsp.set_content_roots(roots.clone());
|
||||
self.lsp.set_jdtls_import_roots(ide_build::maven_external_roots(
|
||||
&self.root,
|
||||
&self.settings.effective().languages.java.maven.linked_projects,
|
||||
));
|
||||
self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone());
|
||||
self.watcher.set_excludes(&excludes);
|
||||
let mut watchers = self.content_watchers.lock();
|
||||
@@ -1239,8 +1247,9 @@ pub(crate) fn workspace_member_roots(root: &Path) -> Vec<PathBuf> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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`).
|
||||
/// Workspace members in manifest order, or extra Project Structure directories, Gradle
|
||||
/// project folders outside the project, and directories of Maven POMs linked from
|
||||
/// outside it (`build_cache` is the Gradle sync 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) {
|
||||
if workspace.is_container(root) {
|
||||
@@ -1332,7 +1341,10 @@ fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, bui
|
||||
.iter()
|
||||
.chain(settings.project_structure.dependencies.iter())
|
||||
.map(|r| PathBuf::from(r.trim()));
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
for p in user
|
||||
.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle))
|
||||
.chain(ide_build::maven_external_roots(root, &settings.languages.java.maven.linked_projects))
|
||||
{
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
@@ -1768,6 +1780,26 @@ mod maven_linkage_tests {
|
||||
assert_eq!(super::jdtls_maven_import(root, &settings).1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_maven_link_is_a_content_root_and_not_its_sibling_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let a = base.join("A");
|
||||
let b = base.join("B");
|
||||
let c = base.join("C");
|
||||
for dir in [&a, &b, &c] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("pom.xml"), "<project/>").unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.languages.java.maven.linked_projects = vec!["../B/pom.xml".into()];
|
||||
assert_eq!(super::content_roots(&settings, &a, &base.join("cache")), vec![b]);
|
||||
// External links must not replace jdtls's scan of the opened root with a build-file list.
|
||||
assert_eq!(super::jdtls_maven_import(&a, &settings), (Vec::new(), None));
|
||||
settings.languages.java.maven.linked_projects.clear();
|
||||
assert!(super::content_roots(&settings, &a, &base.join("cache")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlinked_roots_remain_plain_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -265,6 +265,7 @@ impl Dispatcher {
|
||||
ops::run::start_request(&c.host, &c.window, c.arg("config")?, on_output, c.arg("request")?).await
|
||||
})
|
||||
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
|
||||
.add("web_url", |c| async move { ops::web::url(&c.host, &c.window, &c.arg::<PathBuf>("path")?).await })
|
||||
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
|
||||
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
|
||||
|
||||
@@ -254,6 +254,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "jdk_download_releases"
|
||||
| "jdk_download_location"
|
||||
| "jdk_download"
|
||||
| "jdtls_required_java"
|
||||
| "run_system_env"
|
||||
| "run_gradle_jvm_args_script"
|
||||
| "proxy_check_connection"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn external_maven_link_persists_reloads_and_unlink_keeps_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("A");
|
||||
let sibling = base.join("B");
|
||||
std::fs::create_dir_all(root.join("core")).unwrap();
|
||||
std::fs::create_dir_all(&sibling).unwrap();
|
||||
let a_pom = "<project><artifactId>a</artifactId><packaging>pom</packaging><modules><module>core</module></modules></project>";
|
||||
let b_pom = "<project><artifactId>b</artifactId></project>";
|
||||
std::fs::write(root.join("pom.xml"), a_pom).unwrap();
|
||||
std::fs::write(root.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
|
||||
std::fs::write(sibling.join("pom.xml"), b_pom).unwrap();
|
||||
let config = base.join("config");
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: config.clone(),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
|
||||
let written =
|
||||
serde_json::to_string(&vec!["../B/pom.xml".to_owned(), sibling.join("pom.xml").to_string_lossy().into_owned()])
|
||||
.unwrap();
|
||||
ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", &written)
|
||||
.unwrap();
|
||||
let saved =
|
||||
|| ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.maven.linked_projects;
|
||||
let canonical = dunce::canonicalize(sibling.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/");
|
||||
assert_eq!(saved(), vec![canonical.clone()]);
|
||||
|
||||
let model = ops::build::build_model(&host, "main").await.unwrap();
|
||||
let projects = &model.maven.unwrap().projects;
|
||||
assert_eq!(projects.iter().filter(|p| p.artifact_id == "a").count(), 1);
|
||||
let b = projects.iter().find(|p| p.artifact_id == "b").unwrap();
|
||||
assert!(!b.aggregated);
|
||||
assert_eq!(b.pom, canonical);
|
||||
assert_eq!(dunce::canonicalize(root.join(&b.dir)).unwrap(), sibling);
|
||||
assert_eq!(host.project("main").unwrap().content_roots(), vec![sibling.clone()]);
|
||||
assert_eq!(host.project("main").unwrap().lsp.jdtls_import_roots(), vec![sibling.clone()]);
|
||||
|
||||
let reloaded = ops::build::build_model(&host, "main").await.unwrap();
|
||||
assert_eq!(
|
||||
reloaded.maven.unwrap().projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>(),
|
||||
projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(saved(), vec![canonical]);
|
||||
|
||||
ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", "[]")
|
||||
.unwrap();
|
||||
let gone = ops::build::build_model(&host, "main").await.unwrap();
|
||||
assert!(gone.maven.unwrap().projects.iter().all(|p| p.artifact_id != "b"));
|
||||
assert!(host.project("main").unwrap().content_roots().is_empty());
|
||||
assert!(host.project("main").unwrap().lsp.jdtls_import_roots().is_empty());
|
||||
assert_eq!(saved(), Vec::<String>::new());
|
||||
assert_eq!(std::fs::read_to_string(root.join("pom.xml")).unwrap(), a_pom);
|
||||
assert_eq!(std::fs::read_to_string(sibling.join("pom.xml")).unwrap(), b_pom);
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -126,6 +126,12 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
assert_eq!(current["name"], "demo");
|
||||
|
||||
// The built-in web server runs where the project is; the client forwards its port.
|
||||
let root = std::path::PathBuf::from(opened["root"].as_str().unwrap());
|
||||
let url = invoke(&peer, "web_url", json!({ "path": root.join("src/main.py") })).await.unwrap();
|
||||
let url = url.as_str().unwrap();
|
||||
assert!(url.starts_with("http://127.0.0.1:") && url.ends_with("/demo/src/main.py"), "{url}");
|
||||
|
||||
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
|
||||
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"jdk_download_releases",
|
||||
"jdk_download_location",
|
||||
"jdk_download",
|
||||
"jdtls_required_java",
|
||||
"scaffold_catalog",
|
||||
"scaffold_options",
|
||||
"scaffold_requirements",
|
||||
|
||||
@@ -105,6 +105,56 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn python_effective_interpreter_falls_back_to_the_primary_project() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let primary = base.join("primary");
|
||||
let venv_python =
|
||||
if cfg!(windows) { primary.join(".venv/Scripts/python.exe") } else { primary.join(".venv/bin/python") };
|
||||
std::fs::create_dir_all(venv_python.parent().unwrap()).unwrap();
|
||||
std::fs::write(primary.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap();
|
||||
std::fs::write(&venv_python, "").unwrap();
|
||||
let member = base.join("attached");
|
||||
std::fs::create_dir_all(member.join("src")).unwrap();
|
||||
// The primary project with an attached folder (the "attach" flow, not a container workspace).
|
||||
Workspace::attach(&primary, "Primary", vec![primary.clone(), member.clone()]).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&primary, true, false).unwrap();
|
||||
host.trust.decide(&member, true, false).unwrap();
|
||||
host.open_project("main", &primary).unwrap();
|
||||
let python = |near: Option<&std::path::Path>| {
|
||||
ops::sdk::sdk_python_effective(&host, "main", near.map(|p| p.to_path_buf())).unwrap().map(|p| p.into_path_buf())
|
||||
};
|
||||
// The primary project's own virtualenv, and the bare member inheriting it.
|
||||
assert_eq!(python(Some(&primary)), Some(venv_python.clone()));
|
||||
assert_eq!(python(Some(&member.join("src"))), Some(venv_python.clone()));
|
||||
// The member's own settings win: an explicit interpreter configured for it
|
||||
// (its file is created here — a missing one would be skipped as invalid).
|
||||
let configured = member.join("selected").join(if cfg!(windows) { "python.exe" } else { "python" });
|
||||
std::fs::create_dir_all(configured.parent().unwrap()).unwrap();
|
||||
std::fs::write(&configured, "").unwrap();
|
||||
ide_workspace::SettingsStore::load(&base.join("config"), Some(&member))
|
||||
.set(
|
||||
ide_workspace::SettingsScope::Project,
|
||||
"languages.python.interpreter",
|
||||
serde_json::to_value(configured.to_string_lossy().into_owned()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(python(Some(&member.join("src"))), Some(configured));
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn php_language_level_follows_the_member_of_the_file() {
|
||||
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
|
||||
|
||||
@@ -55,6 +55,7 @@ tree-sitter-xml = "0.7.0"
|
||||
tree-sitter-haskell = "0.24.1"
|
||||
tree-sitter-dart = "0.2.0"
|
||||
tree-sitter-graphql = "0.3.0"
|
||||
tree-sitter-prisma-io = "1.6.0"
|
||||
tree-sitter-gdscript = "6.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
; Prisma schema highlighting (our captures, not the grammar crate's). Later patterns win.
|
||||
|
||||
(identifier) @variable
|
||||
(developer_comment) @comment
|
||||
(comment) @comment.doc
|
||||
(number) @number
|
||||
(string) @string
|
||||
[(true) (false) (null)] @keyword
|
||||
|
||||
(variable) @property
|
||||
(column_declaration . (identifier) @property)
|
||||
(type_expression . (identifier) @variable.parameter)
|
||||
(enumeral (identifier) @constant)
|
||||
|
||||
(column_type (identifier) @type)
|
||||
(column_type (call_expression (identifier) @type))
|
||||
(call_expression (identifier) @function)
|
||||
|
||||
(model_declaration (identifier) @type)
|
||||
(view_declaration (identifier) @type)
|
||||
(type_declaration (identifier) @type)
|
||||
(type_declaration (type_declaration_type) @type)
|
||||
(enum_declaration (identifier) @type)
|
||||
(datasource_declaration (identifier) @type)
|
||||
(generator_declaration (identifier) @type)
|
||||
|
||||
(attribute (identifier) @attribute)
|
||||
(attribute (call_expression (identifier) @attribute))
|
||||
(attribute (member_expression (identifier) @attribute (property_identifier) @attribute))
|
||||
(attribute (call_expression (member_expression (identifier) @attribute (property_identifier) @attribute)))
|
||||
(block_attribute_declaration (identifier) @attribute)
|
||||
(block_attribute_declaration (call_expression (identifier) @attribute))
|
||||
["@" "@@"] @attribute
|
||||
|
||||
["datasource" "enum" "generator" "model" "type" "view"] @keyword
|
||||
|
||||
["(" ")" "[" "]" "{" "}"] @punctuation.bracket
|
||||
["," ":" "."] @punctuation.delimiter
|
||||
(maybe) @operator
|
||||
"=" @operator
|
||||
@@ -0,0 +1,8 @@
|
||||
(model_declaration (identifier) @name) @def.class
|
||||
(view_declaration (identifier) @name) @def.class
|
||||
(type_declaration . (identifier) @name (statement_block)) @def.struct
|
||||
(enum_declaration (identifier) @name) @def.enum
|
||||
(enumeral (identifier) @name) @def.enum_member
|
||||
(datasource_declaration (identifier) @name) @outline.module
|
||||
(generator_declaration (identifier) @name) @outline.module
|
||||
(column_declaration . (identifier) @name) @def.field
|
||||
@@ -67,6 +67,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("php", include_str!("../../../languages/php.toml")),
|
||||
("plaintext", include_str!("../../../languages/plaintext.toml")),
|
||||
("powershell", include_str!("../../../languages/powershell.toml")),
|
||||
("prisma", include_str!("../../../languages/prisma.toml")),
|
||||
("properties", include_str!("../../../languages/properties.toml")),
|
||||
("proto", include_str!("../../../languages/proto.toml")),
|
||||
("python", include_str!("../../../languages/python.toml")),
|
||||
@@ -1026,6 +1027,7 @@ mod tests {
|
||||
("CMakeLists.txt", "cmake"),
|
||||
("api.proto", "proto"),
|
||||
("schema.graphql", "graphql"),
|
||||
("schema.prisma", "prisma"),
|
||||
("main.tf", "terraform"),
|
||||
("prod.tfvars", "terraform-vars"),
|
||||
("job.nomad", "hcl"),
|
||||
|
||||
@@ -430,6 +430,16 @@ pub(crate) static SPECS: &[Spec] = &[
|
||||
imports: NONE,
|
||||
constructors: NONE,
|
||||
},
|
||||
Spec {
|
||||
name: "prisma",
|
||||
language: tree_sitter_prisma_io::LANGUAGE,
|
||||
outline: outline!("prisma"),
|
||||
highlights: Some(Highlights { query: highlights!("prisma"), first_wins: false }),
|
||||
fold_nodes: NONE,
|
||||
fold_bodies: NONE,
|
||||
imports: NONE,
|
||||
constructors: NONE,
|
||||
},
|
||||
Spec {
|
||||
name: "make",
|
||||
language: tree_sitter_make::LANGUAGE,
|
||||
|
||||
@@ -501,6 +501,28 @@ mod tests {
|
||||
("NEWHOPE", K::Constant),
|
||||
],
|
||||
),
|
||||
(
|
||||
"prisma",
|
||||
"// hi\n/// Doc\nmodel User {\n id Int @id @default(autoincrement())\n email String? @db.VarChar(255)\n role Role\n @@index([email])\n}\nenum Role { ADMIN }\ndatasource db {\n provider = \"postgresql\"\n}\n",
|
||||
&[
|
||||
("// hi", K::Comment),
|
||||
("/// Doc", K::DocComment),
|
||||
("model", K::Keyword),
|
||||
("User", K::Type),
|
||||
("id", K::Property),
|
||||
("Int", K::Type),
|
||||
("@id", K::Attribute),
|
||||
("@default", K::Attribute),
|
||||
("autoincrement", K::FunctionCall),
|
||||
("VarChar", K::Attribute),
|
||||
("255", K::Number),
|
||||
("Role", K::Type),
|
||||
("@@index", K::Attribute),
|
||||
("ADMIN", K::Constant),
|
||||
("provider", K::Property),
|
||||
("\"postgresql\"", K::String),
|
||||
],
|
||||
),
|
||||
(
|
||||
"hcl",
|
||||
"# hi\nresource \"aws_instance\" \"web\" {\n ami = var.ami\n count = 2\n tags = { Name = \"web-${var.env}\" }\n name = upper(\"x\")\n}\n",
|
||||
|
||||
@@ -886,6 +886,17 @@ fn outline_more_languages() {
|
||||
Function GetUser
|
||||
",
|
||||
);
|
||||
check_outline(
|
||||
"prisma",
|
||||
"datasource db {\n provider = \"sqlite\"\n}\nmodel User {\n id Int @id\n @@map(\"users\")\n}\nenum Role {\n ADMIN\n}\n",
|
||||
"
|
||||
Module db
|
||||
Class User
|
||||
Field id
|
||||
Enum Role
|
||||
EnumMember ADMIN
|
||||
",
|
||||
);
|
||||
check_outline(
|
||||
"nix",
|
||||
"{ pkgs, ... }:\nlet\n foo = 1;\n bar = x: x;\nin { inherit foo; }\n",
|
||||
|
||||
@@ -430,6 +430,9 @@ impl Handler for ClientHandler {
|
||||
crate::kotlin_lsp::record_import_state(&self.server, self.generation, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
if self.server.running_gen(self.generation).is_some() {
|
||||
self.server.set_java_ready();
|
||||
}
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ProjectStatus" => {
|
||||
|
||||
@@ -201,6 +201,10 @@ pub struct LspManager {
|
||||
me: Weak<LspManager>,
|
||||
project_root: PathBuf,
|
||||
content_roots: RwLock<Vec<PathBuf>>,
|
||||
/// Maven projects linked from outside `project_root`. jdtls scans them as extra
|
||||
/// workspace folders; `projectConfigurations` stays unset so the opened root is
|
||||
/// still discovered automatically.
|
||||
jdtls_import_roots: RwLock<Vec<PathBuf>>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
sink: SharedSink,
|
||||
launcher: Arc<dyn Launcher>,
|
||||
@@ -444,6 +448,7 @@ impl LspManager {
|
||||
me: me.clone(),
|
||||
project_root,
|
||||
content_roots: RwLock::new(Vec::new()),
|
||||
jdtls_import_roots: RwLock::new(Vec::new()),
|
||||
languages,
|
||||
sink: {
|
||||
let me = me.clone();
|
||||
@@ -503,6 +508,28 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories of Maven projects linked from outside the opened root. The jdtls
|
||||
/// rooted at the opened project scans them together with that root. An empty list
|
||||
/// leaves discovery as the single `rootUri` scan.
|
||||
pub fn set_jdtls_import_roots(&self, mut roots: Vec<PathBuf>) {
|
||||
roots.retain(|root| root.is_absolute() && root.is_dir() && !root.starts_with(&self.project_root));
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
let changed = {
|
||||
let mut current = self.jdtls_import_roots.write();
|
||||
let changed = *current != roots;
|
||||
*current = roots;
|
||||
changed
|
||||
};
|
||||
if changed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jdtls_import_roots(&self) -> Vec<PathBuf> {
|
||||
self.jdtls_import_roots.read().clone()
|
||||
}
|
||||
|
||||
/// Shares JDT LS within this manager; callers reconfigure after the hook's value changes.
|
||||
pub fn set_jdtls_shared_hook(&self, hook: Box<JdtlsSharedFn>) {
|
||||
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
|
||||
@@ -537,10 +564,29 @@ impl LspManager {
|
||||
if state.shared {
|
||||
return state.roots.clone();
|
||||
}
|
||||
// The opened project's server also scans linked external Maven projects.
|
||||
// Other in-root builds keep their own server and their own root.
|
||||
if server.root == self.project_root {
|
||||
return self.project_and_import_roots();
|
||||
}
|
||||
}
|
||||
vec![server.root.clone()]
|
||||
}
|
||||
|
||||
fn project_and_import_roots(&self) -> Vec<PathBuf> {
|
||||
let mut roots = vec![self.project_root.clone()];
|
||||
for root in self.jdtls_import_roots.read().iter() {
|
||||
if root.is_absolute() && root.is_dir() && !roots.contains(root) {
|
||||
roots.push(root.clone());
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn imports_external_java(&self, config: &LanguageServerConfig, path: &Path) -> bool {
|
||||
is_local_jdtls(config) && self.jdtls_import_roots.read().iter().any(|root| path.starts_with(root))
|
||||
}
|
||||
|
||||
fn project_root_for(&self, path: &Path) -> PathBuf {
|
||||
self.content_roots
|
||||
.read()
|
||||
@@ -557,7 +603,9 @@ impl LspManager {
|
||||
let mut servers = self.servers.lock();
|
||||
let project = self.project_root_for(path);
|
||||
let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten();
|
||||
let root = if shared.as_ref().is_some_and(|state| state.shared) {
|
||||
let root = if shared.as_ref().is_some_and(|state| state.shared) || self.imports_external_java(cfg, path) {
|
||||
// One server for the opened root and a linked external Maven project, so
|
||||
// Java completion and navigation see the import jdtls made of both.
|
||||
self.project_root.clone()
|
||||
} else {
|
||||
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
|
||||
@@ -953,13 +1001,15 @@ impl LspManager {
|
||||
}
|
||||
init_options["settings"] = handler::all_settings(&cfg);
|
||||
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
|
||||
if shared {
|
||||
init_options["workspaceFolders"] = json!(
|
||||
self.server_workspace_roots(server)
|
||||
.iter()
|
||||
.map(|root| uri::path_to_uri_string(root))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
// jdtls imports `initializationOptions.workspaceFolders` by scanning each
|
||||
// directory. It ignores the LSP workspace folder list for that first import,
|
||||
// and a `projectConfigurations` list replaces the scan. Extra Maven projects
|
||||
// are therefore folders here, not an explicit build-file list, so projects
|
||||
// under the opened root are still discovered.
|
||||
let folders: Vec<String> =
|
||||
self.server_workspace_roots(server).iter().map(|root| uri::path_to_uri_string(root)).collect();
|
||||
if shared || folders.len() > 1 {
|
||||
init_options["workspaceFolders"] = json!(folders);
|
||||
}
|
||||
let triggers: Vec<_> = self
|
||||
.docs
|
||||
|
||||
@@ -80,7 +80,7 @@ pub(crate) struct Widened<R> {
|
||||
impl<R> Widened<R> {
|
||||
/// An answer from the server's analysis scope alone; `empty`: it found nothing.
|
||||
pub fn only(first: R, empty: bool, server: &Server) -> Self {
|
||||
let incomplete = (empty && server.is_busy()).then_some(ReferencesIncomplete::Indexing);
|
||||
let incomplete = (empty && server.indexing()).then_some(ReferencesIncomplete::Indexing);
|
||||
Self { first, second: None, incomplete }
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ impl LspManager {
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
let busy = t.server.is_busy();
|
||||
let indexing = t.server.indexing();
|
||||
// gopls searches the view of the file asked about; files opened for it only get views of
|
||||
// their own (see `go_scope`).
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
|
||||
@@ -143,7 +143,7 @@ impl LspManager {
|
||||
};
|
||||
let (first, candidates) = futures::join!(query(), candidates);
|
||||
let mut first = first?;
|
||||
if busy && covered(&first).is_empty() && t.server.wait_idle(INDEX_WAIT, cancel).await {
|
||||
if indexing && covered(&first).is_empty() && t.server.wait_indexed(INDEX_WAIT, cancel).await {
|
||||
first = query().await?;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
@@ -151,7 +151,7 @@ impl LspManager {
|
||||
}
|
||||
// Servers report analysis progress often; only "nothing found" while it lasts is suspect.
|
||||
let mut incomplete =
|
||||
(t.server.is_busy() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
|
||||
(t.server.indexing() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
|
||||
let Some(candidates) = candidates else { return Ok(Widened { first, second: None, incomplete }) };
|
||||
if candidates.limit_hit {
|
||||
incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles));
|
||||
|
||||
@@ -127,7 +127,7 @@ pub struct Server {
|
||||
published: Mutex<HashMap<PathBuf, Instant>>,
|
||||
/// Woken on every `publishDiagnostics` (Inspect Code waits on it).
|
||||
pub published_notify: Notify,
|
||||
/// Woken when the last `$/progress` task ends.
|
||||
/// Woken when an empty answer stops being suspect (see [`Server::indexing`]).
|
||||
idle_notify: Notify,
|
||||
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
|
||||
/// one, then whether it is still loading the project.
|
||||
@@ -148,6 +148,8 @@ pub struct Server {
|
||||
/// The instance whose just registered external project is loading, until an open file's
|
||||
/// diagnostics arrive (0: none).
|
||||
pub external_loading: std::sync::atomic::AtomicU64,
|
||||
/// jdtls sent `ServiceReady` on this instance: the project is imported.
|
||||
java_ready: std::sync::atomic::AtomicBool,
|
||||
/// kotlin-lsp's project import as the current instance last reported it.
|
||||
pub(crate) import_state: watch::Sender<Option<crate::kotlin_lsp::ImportState>>,
|
||||
}
|
||||
@@ -195,6 +197,7 @@ impl Server {
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
external_loading: std::sync::atomic::AtomicU64::new(0),
|
||||
java_ready: std::sync::atomic::AtomicBool::new(false),
|
||||
import_state: watch::channel(None).0,
|
||||
}
|
||||
}
|
||||
@@ -596,6 +599,22 @@ impl Server {
|
||||
self.phase_ended();
|
||||
}
|
||||
|
||||
/// Whether an empty search answer may only mean the server has not indexed the project
|
||||
/// yet. jdtls' searches wait for its index themselves, and the progress it reports once
|
||||
/// the project is imported is background work (builds, validation): only the import counts.
|
||||
pub fn indexing(&self) -> bool {
|
||||
if self.name == "jdtls" {
|
||||
return self.is_busy() && !self.java_ready.load(std::sync::atomic::Ordering::Acquire);
|
||||
}
|
||||
self.is_busy()
|
||||
}
|
||||
|
||||
/// Records jdtls' `ServiceReady`.
|
||||
pub fn set_java_ready(&self) {
|
||||
self.java_ready.store(true, std::sync::atomic::Ordering::Release);
|
||||
self.phase_ended();
|
||||
}
|
||||
|
||||
/// Whether progress token `token` is still running on this instance.
|
||||
pub fn has_progress(&self, token: &str) -> bool {
|
||||
self.progress.lock().contains_key(token)
|
||||
@@ -613,7 +632,7 @@ impl Server {
|
||||
|
||||
fn phase_ended(&self) {
|
||||
self.phase_notify.notify_waiters();
|
||||
if !self.is_busy() {
|
||||
if !self.indexing() {
|
||||
self.idle_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
@@ -623,17 +642,17 @@ impl Server {
|
||||
self.phase_notify.notified()
|
||||
}
|
||||
|
||||
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
|
||||
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
/// Waits until [`Server::indexing`] ends; false on timeout or cancellation.
|
||||
pub async fn wait_indexed(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let notified = self.idle_notify.notified();
|
||||
if !self.is_busy() || self.running().is_none() {
|
||||
return !self.is_busy();
|
||||
if !self.indexing() || self.running().is_none() {
|
||||
return !self.indexing();
|
||||
}
|
||||
tokio::select! {
|
||||
() = notified => {}
|
||||
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
|
||||
() = tokio::time::sleep_until(deadline) => return !self.indexing(),
|
||||
() = cancel.cancelled() => return false,
|
||||
}
|
||||
}
|
||||
@@ -723,6 +742,7 @@ impl Server {
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
|
||||
self.java_ready.store(false, std::sync::atomic::Ordering::Release);
|
||||
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
|
||||
}
|
||||
|
||||
@@ -957,7 +977,7 @@ mod tests {
|
||||
stalled: true,
|
||||
},
|
||||
);
|
||||
assert!(!s.is_busy(), "a stuck task must not hold wait_idle or ReferencesIncomplete::Indexing");
|
||||
assert!(!s.is_busy(), "a stuck task must not hold wait_indexed or ReferencesIncomplete::Indexing");
|
||||
s.progress.lock().get_mut("p").unwrap().stalled = false;
|
||||
assert!(s.is_busy(), "a reporting task keeps the server busy");
|
||||
}
|
||||
|
||||
@@ -759,6 +759,40 @@ async fn java_classpath_does_not_retry_permanently_unavailable_servers() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn external_maven_import_joins_the_opened_root_without_replacing_discovery() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
let external = dunce::canonicalize(external.path()).unwrap();
|
||||
std::fs::write(external.join("pom.xml"), "<project><artifactId>b</artifactId></project>").unwrap();
|
||||
let outside = external.join("src/Main.java");
|
||||
std::fs::create_dir_all(outside.parent().unwrap()).unwrap();
|
||||
std::fs::write(&outside, "class External {}").unwrap();
|
||||
let inside = h.root.join("src/App.java");
|
||||
std::fs::create_dir_all(inside.parent().unwrap()).unwrap();
|
||||
std::fs::write(&inside, "class App {}").unwrap();
|
||||
// An in-root build with its own marker keeps a separate server.
|
||||
let nested = h.root.join("nested");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(nested.join("mock.toml"), "").unwrap();
|
||||
let nested_file = nested.join("Nested.java");
|
||||
std::fs::write(&nested_file, "class Nested {}").unwrap();
|
||||
|
||||
h.mgr.set_jdtls_import_roots(vec![external.clone()]);
|
||||
h.mgr.did_open(DocId::next(), &inside, &h.lang, 1, "class App {}");
|
||||
h.mgr.did_open(DocId::next(), &outside, &h.lang, 1, "class External {}");
|
||||
h.mgr.did_open(DocId::next(), &nested_file, &h.lang, 1, "class Nested {}");
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
let inits = state.requested("initialize");
|
||||
let project = inits.iter().find(|init| init["rootUri"] == uri_of(&h.root)).unwrap();
|
||||
assert_eq!(project["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)]));
|
||||
assert!(project["initializationOptions"].get("projectConfigurations").is_none());
|
||||
assert!(state.requested("initialize").iter().any(|init| init["rootUri"] == uri_of(&nested)));
|
||||
assert_eq!(h.mgr.servers().len(), 2);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shared_java_external_roots_reach_initialization_queries_and_watches() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
@@ -4925,6 +4959,39 @@ async fn scoped_java_usages_search_the_sources_unless_libraries_are_asked_for()
|
||||
assert!(seen.lock().is_empty());
|
||||
}
|
||||
|
||||
/// jdtls' searches wait for its index themselves, and it reports background work (builds,
|
||||
/// validation) for long stretches: a method nothing calls is answered at once meanwhile.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_usages_of_an_unused_method_do_not_wait_for_background_work() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
s.respond("workspace/executeCommand", json!([]));
|
||||
let (doc, _) = h.open("a.java", "void unused() {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
s.peer()
|
||||
.notify("$/progress", json!({ "token": "build", "value": { "kind": "begin", "title": "Building" } }))
|
||||
.unwrap();
|
||||
h.wait_status("jdtls", ServerStatus::Busy).await;
|
||||
// Until the project is imported, finding nothing may only mean it is not indexed yet.
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 6), true, false, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
|
||||
s.peer().notify("language/status", json!({"type": "ServiceReady", "message": "ServiceReady"})).unwrap();
|
||||
for _ in 0..200 {
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 6), true, false, Default::default()).await.unwrap();
|
||||
if refs.incomplete.is_none() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 6), true, true, Default::default()).await.unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(2), "waited {:?}", started.elapsed());
|
||||
assert!(refs.locations.is_empty());
|
||||
assert_eq!(refs.incomplete, None);
|
||||
}
|
||||
|
||||
/// A library method's usages in the sources are shown before the dependencies and the JDK
|
||||
/// are searched, which takes seconds; scope widening searches the sources alone again.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -14,6 +14,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
globset.workspace = true
|
||||
|
||||
@@ -10,6 +10,9 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Remote folders listed (or files checked) at the same time.
|
||||
pub(crate) const LIST_CONCURRENCY: usize = 16;
|
||||
|
||||
/// Mapping eligibility uses a common synthetic server root: mapping and exclusion
|
||||
/// paths are both relative to that root, so checking never opens a connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -199,20 +202,35 @@ impl Deployment<'_> {
|
||||
}
|
||||
|
||||
/// Remote files under `dir` (recursively), excluded ones left out.
|
||||
/// Files under `dir`, by path. Lists [`LIST_CONCURRENCY`] folders at a time: one round trip
|
||||
/// per folder after another made large trees take minutes.
|
||||
async fn remote_files(&self, dir: &str, out: &mut Vec<ide_ssh::RemoteEntry>) -> IdeResult<()> {
|
||||
for e in self.fs.list(dir).await? {
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
let start = out.len();
|
||||
let mut pending = vec![dir.to_owned()];
|
||||
let mut listing = FuturesUnordered::new();
|
||||
loop {
|
||||
while listing.len() < LIST_CONCURRENCY
|
||||
&& let Some(dir) = pending.pop()
|
||||
{
|
||||
listing.push(async move { self.fs.list(&dir).await });
|
||||
}
|
||||
let Some(entries) = listing.next().await else { break };
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if self.exclude.remote(&e.path) {
|
||||
continue;
|
||||
}
|
||||
if e.is_dir && !e.is_symlink {
|
||||
Box::pin(self.remote_files(&e.path, out)).await?;
|
||||
} else if !e.is_dir {
|
||||
out.push(e);
|
||||
for e in entries? {
|
||||
if self.exclude.remote(&e.path) {
|
||||
continue;
|
||||
}
|
||||
if e.is_dir && !e.is_symlink {
|
||||
pending.push(e.path);
|
||||
} else if !e.is_dir {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
out[start..].sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -470,13 +470,27 @@ impl RemoteService {
|
||||
preserve_time: true,
|
||||
cancel: CancellationToken::new(),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
out(OutputChunk::System { text: format!("Comparing project files with {remote_root}...\n") });
|
||||
let mut progress = |_: &str, _: usize, _: usize| {};
|
||||
let report = d.push_changes(&root, &mut progress).await;
|
||||
let synced = sync_external_libraries(&fs, &libraries, preserve_time).await;
|
||||
if report.is_ok() && !libraries.is_empty() {
|
||||
out(OutputChunk::System {
|
||||
text: format!("Checking {} external classpath entries...\n", libraries.len()),
|
||||
});
|
||||
}
|
||||
let synced = match &report {
|
||||
Ok(_) => sync_external_libraries(&fs, &libraries, preserve_time).await,
|
||||
Err(_) => Ok(0),
|
||||
};
|
||||
fs.close().await;
|
||||
let report = report?;
|
||||
out(OutputChunk::System {
|
||||
text: format!("Uploaded {} changed file(s) to {remote_root}\n", report.files),
|
||||
text: format!(
|
||||
"Uploaded {} changed file(s) to {remote_root} in {:.1}s\n",
|
||||
report.files,
|
||||
started.elapsed().as_secs_f64()
|
||||
),
|
||||
});
|
||||
match report.errors.first() {
|
||||
Some(e) => Err(IdeError::new(
|
||||
@@ -517,9 +531,8 @@ async fn sync_external_libraries(
|
||||
fn fail(path: &str, e: IdeError) -> IdeError {
|
||||
IdeError::new(ide_core::error::ErrorKind::Io, format!("sync of {path} failed: {}", e.message))
|
||||
}
|
||||
let mut synced = 0;
|
||||
let mut files = Vec::new();
|
||||
for (local, remote_base) in libraries {
|
||||
let mut files = Vec::new();
|
||||
if local.is_dir() {
|
||||
let walk = ignore::WalkBuilder::new(local).standard_filters(false).follow_links(false).build();
|
||||
for entry in walk.flatten() {
|
||||
@@ -534,7 +547,11 @@ async fn sync_external_libraries(
|
||||
} else {
|
||||
files.push((local.clone(), remote_base.clone()));
|
||||
}
|
||||
for (file, remote) in files {
|
||||
}
|
||||
// The remote copies are checked several at a time (one round trip each); uploads follow.
|
||||
use futures::stream::{self, StreamExt, TryStreamExt};
|
||||
let stale: Vec<(PathBuf, String)> = stream::iter(files)
|
||||
.map(|(file, remote)| async move {
|
||||
let size = std::fs::metadata(&file).map_err(|e| fail(&remote, e.into()))?.len();
|
||||
let local_stamp = FileStamp { size, mtime: mtime_of(&file) };
|
||||
let remote_stamp = match fs.stat(&remote).await.map_err(|e| fail(&remote, e))? {
|
||||
@@ -544,14 +561,17 @@ async fn sync_external_libraries(
|
||||
Some(e) => Some(FileStamp { size: e.size, mtime: e.mtime }),
|
||||
None => None,
|
||||
};
|
||||
if matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same) {
|
||||
continue;
|
||||
}
|
||||
fs.upload(&file, &remote, preserve_time).await.map_err(|e| fail(&remote, e))?;
|
||||
synced += 1;
|
||||
}
|
||||
let same = matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same);
|
||||
Ok((!same).then_some((file, remote)))
|
||||
})
|
||||
.buffered(crate::deploy::LIST_CONCURRENCY)
|
||||
.try_filter_map(|x| async move { Ok(x) })
|
||||
.try_collect()
|
||||
.await?;
|
||||
for (file, remote) in &stale {
|
||||
fs.upload(file, remote, preserve_time).await.map_err(|e| fail(remote, e))?;
|
||||
}
|
||||
Ok(synced)
|
||||
Ok(stale.len())
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
|
||||
@@ -263,10 +263,15 @@ fn target_command(
|
||||
RemoteCommand { program, args, cwd, env, shell_line: None }
|
||||
}
|
||||
|
||||
/// How long Stop waits for the channel to close after `KILL`.
|
||||
const KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
struct RemoteRun {
|
||||
info: Mutex<RunInfo>,
|
||||
process: Mutex<Option<Arc<RemoteProcess>>>,
|
||||
cancel: CancellationToken,
|
||||
/// Stop gave up waiting for the channel to report the exit: the run finishes locally.
|
||||
abandon: CancellationToken,
|
||||
stopping: AtomicBool,
|
||||
/// The runtime it started on: stdin / stop requests may come from threads without one.
|
||||
runtime: tokio::runtime::Handle,
|
||||
@@ -318,6 +323,7 @@ impl RemoteRuns {
|
||||
info: Mutex::new(info.clone()),
|
||||
process: Mutex::new(None),
|
||||
cancel: CancellationToken::new(),
|
||||
abandon: CancellationToken::new(),
|
||||
stopping: AtomicBool::new(false),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
});
|
||||
@@ -361,7 +367,8 @@ impl RemoteRuns {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `INT`, then `KILL` (closing the channel) after `grace`.
|
||||
/// `INT`, then `KILL` (closing the channel) after `grace`; a channel that still reports no
|
||||
/// exit after another [`KILL_WAIT`] is left behind and the run finishes as stopped.
|
||||
pub fn stop(&self, id: RunId, grace: std::time::Duration) -> IdeResult<()> {
|
||||
let run =
|
||||
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?;
|
||||
@@ -374,6 +381,8 @@ impl RemoteRuns {
|
||||
tokio::time::sleep(grace).await;
|
||||
if run.info.lock().running {
|
||||
let _ = process.signal(Sig::KILL).await;
|
||||
tokio::time::sleep(KILL_WAIT).await;
|
||||
run.abandon.cancel();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
@@ -420,7 +429,10 @@ async fn run_remote(
|
||||
.await?;
|
||||
let process = Arc::new(process);
|
||||
*run.process.lock() = Some(process.clone());
|
||||
Ok(process.wait().await)
|
||||
tokio::select! {
|
||||
code = process.wait() => Ok(code),
|
||||
_ = run.abandon.cancelled() => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -66,16 +66,19 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<Pat
|
||||
|
||||
/// The project interpreter: the configured one, else a project virtualenv
|
||||
/// (`.venv`, `venv`, …) or pixi environment (`.pixi/envs/<env>`) in the project
|
||||
/// root, else the Python the project pins for mise / asdf.
|
||||
/// root, else the Python the project pins for mise / asdf. A configured
|
||||
/// interpreter whose executable is gone (uninstalled, moved) is skipped like
|
||||
/// PyCharm's "Invalid" SDKs, so runs fall back instead of failing on a dead path.
|
||||
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
setting_path(root, &s.languages.python.interpreter)
|
||||
.filter(|p| p.is_file())
|
||||
.or_else(|| project_venv_python(root))
|
||||
.or_else(|| pinned::pinned_python(root))
|
||||
}
|
||||
|
||||
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
|
||||
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
|
||||
if let Some(python) = setting_path(root, &s.languages.python.interpreter) {
|
||||
if let Some(python) = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file()) {
|
||||
return Some(python);
|
||||
}
|
||||
let near = near.filter(|dir| dir.starts_with(root)).unwrap_or(root);
|
||||
@@ -1446,7 +1449,22 @@ pub fn apply_maven_env(c: &mut RunConfig, root: &Path, s: &Settings) {
|
||||
/// cargo / go configs the selected toolchain, Maven / Gradle their runner JDK
|
||||
/// (and Maven the configured home and options).
|
||||
pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) {
|
||||
let explicit_python = setting_path(root, &s.languages.python.interpreter);
|
||||
apply_to_run_configs_in(configs, root, None, s)
|
||||
}
|
||||
|
||||
/// [`apply_to_run_configs`] with an inherited fallback project: a workspace member
|
||||
/// (`root`) with no Python of its own (no interpreter setting, virtualenv or pin)
|
||||
/// runs on the primary project's (`fallback`) — the module SDK a member without its
|
||||
/// own inherits in IDEA. `None` keeps the configuration's own project.
|
||||
pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback: Option<&Path>, s: &Settings) {
|
||||
let inherited = fallback.filter(|f| *f != root);
|
||||
// An interpreter whose executable is gone is skipped (like PyCharm's "Invalid"
|
||||
// SDKs): the run configuration uses the venv / fallback instead of a dead path.
|
||||
let explicit_python = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file());
|
||||
let inherited_python = inherited
|
||||
.and_then(|f| setting_path(f, &s.languages.python.interpreter))
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|_| explicit_python.is_none());
|
||||
let pm = non_empty(&s.languages.node.package_manager);
|
||||
let php = setting_path(root, &s.languages.php.interpreter);
|
||||
let rust = rust_bin(root, s);
|
||||
@@ -1464,7 +1482,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
// A module outside the project (an external linked build) keeps the project's choices.
|
||||
let near = if owner.starts_with(root) { owner } else { root };
|
||||
match tool {
|
||||
"python" => python_interpreter_near(root, Some(near), s),
|
||||
// A member with no Python of its own runs on the primary project's.
|
||||
"python" => python_interpreter_near(root, Some(near), s)
|
||||
.or_else(|| inherited.and_then(|f| python_interpreter_near(f, Some(f), s))),
|
||||
"node" if non_empty(&s.languages.node.interpreter).is_none() => {
|
||||
node_dir(near, s).or_else(|| node_dir(root, s))
|
||||
}
|
||||
@@ -1496,7 +1516,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
let mut path_dirs: Vec<PathBuf> = Vec::new();
|
||||
match c.kind.as_str() {
|
||||
"python" | "pytest" | "django-test" => {
|
||||
if let Some(py) = &explicit_python {
|
||||
let python = toolchain("python", &owner);
|
||||
// The member's explicit interpreter, else the inherited one, else the
|
||||
// member's own venv / pin.
|
||||
if let Some(py) = explicit_python.as_ref().or(inherited_python.as_ref()).or(python.as_ref()) {
|
||||
let runner = PY_RUNNERS.contains(&c.command.as_str());
|
||||
if (c.kind == "python" || c.kind == "django-test") && (runner || is_python_command(&c.command)) {
|
||||
if runner && c.args.first().map(String::as_str) == Some("run") {
|
||||
@@ -1515,7 +1538,6 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
c.args = ["-m".to_owned(), "pytest".to_owned()].into_iter().chain(rest).collect();
|
||||
}
|
||||
}
|
||||
let python = toolchain("python", &owner);
|
||||
if let Some(venv) = python.as_deref().and_then(venv_of)
|
||||
&& !c.env.contains_key("VIRTUAL_ENV")
|
||||
{
|
||||
@@ -1653,9 +1675,16 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
/// `VIRTUAL_ENV`, `JAVA_HOME`, `GOROOT`, …) or an absolute command its bare name
|
||||
/// resolves to (the project interpreter, a toolchain's `cargo` / `go`, Maven's `mvn`).
|
||||
pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConfig {
|
||||
without_derived_in(config, root, None, s)
|
||||
}
|
||||
|
||||
/// [`without_derived`] with the inherited fallback project the configuration runs with
|
||||
/// (see [`apply_to_run_configs_in`]): a derived command or environment entry is derived
|
||||
/// only when re-applying settings with the same fallback reproduces it.
|
||||
pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Path>, s: &Settings) -> RunConfig {
|
||||
let applied = |c: &RunConfig| {
|
||||
let mut c = c.clone();
|
||||
apply_to_run_configs(std::slice::from_mut(&mut c), root, s);
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut c), root, fallback, s);
|
||||
c
|
||||
};
|
||||
let mut out = config.clone();
|
||||
|
||||
@@ -282,10 +282,16 @@ pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
|
||||
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
|
||||
/// an older JDK the server dies resolving its bundles.
|
||||
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
|
||||
let required = crate::jdtls::script_for(cfg)
|
||||
jdtls_required_java(cfg).unwrap_or(JDTLS_MIN_JAVA)
|
||||
}
|
||||
|
||||
/// [`jdtls_min_java`] when the installed jdtls tells; `None` when none is
|
||||
/// installed or its bundles can't be read.
|
||||
pub fn jdtls_required_java(cfg: &LanguageServerConfig) -> Option<u32> {
|
||||
crate::jdtls::script_for(cfg)
|
||||
.and_then(|script| Some(script.parent()?.parent()?.to_path_buf()))
|
||||
.and_then(|home| bundles_min_java(&home));
|
||||
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
|
||||
.and_then(|home| bundles_min_java(&home))
|
||||
.map(|v| v.max(JDTLS_MIN_JAVA))
|
||||
}
|
||||
|
||||
/// Per install: (plugins directory mtime, its requirement).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::apply::{
|
||||
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
|
||||
ruff_configuration, terminal_activation, tool_install_environment, tsdk, without_derived,
|
||||
apply_to_run_configs, apply_to_run_configs_in, configure_server, jdtls_jdk, maven_command, maven_options,
|
||||
project_jdk, python_interpreter, ruff_configuration, terminal_activation, tool_install_environment, tsdk,
|
||||
without_derived, without_derived_in,
|
||||
};
|
||||
use crate::python::parse_pip_list;
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
@@ -62,6 +63,14 @@ fn run(kind: &str, command: &str, args: &[&str]) -> RunConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// An interpreter executable at `rel` under `root` (resolution skips paths that don't exist).
|
||||
fn fake_interpreter(root: &Path, rel: &str) -> std::path::PathBuf {
|
||||
let path = root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "").unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_venv_is_the_default_interpreter() {
|
||||
if cfg!(windows) {
|
||||
@@ -74,8 +83,10 @@ fn project_venv_is_the_default_interpreter() {
|
||||
assert_eq!(python_interpreter(tmp.path(), &s), Some(py.clone()));
|
||||
assert_eq!(venv_of(&py), Some(tmp.path().join(".venv")));
|
||||
let mut s = Settings::default();
|
||||
s.languages.python.interpreter = "/opt/py/bin/python3".into();
|
||||
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
|
||||
// A configured interpreter wins over the virtualenv while its executable exists.
|
||||
let configured = fake_interpreter(tmp.path(), "opt/py/bin/python3");
|
||||
s.languages.python.interpreter = configured.to_string_lossy().into_owned();
|
||||
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), configured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -304,7 +315,9 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.python.interpreter = "/opt/py/bin/python3".into();
|
||||
let python = fake_interpreter(tmp.path(), "opt/py/bin/python3");
|
||||
let python = python.to_string_lossy().into_owned();
|
||||
s.languages.python.interpreter = python.clone();
|
||||
s.languages.node.package_manager = "pnpm".into();
|
||||
let mut configs = vec![
|
||||
run("python", "uv", &["run", "python", "-m", "app"]),
|
||||
@@ -316,17 +329,17 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
|
||||
run("django-test", ".venv/bin/python", &["manage.py", "test", "polls"]),
|
||||
];
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
assert_eq!(configs[0].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[0].command, python);
|
||||
assert_eq!(configs[0].args, ["-m", "app"]);
|
||||
assert_eq!(configs[1].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[1].command, python);
|
||||
assert_eq!(configs[1].args, ["-m", "pytest"]);
|
||||
assert_eq!(configs[2].args, ["-m", "pytest", "-x"]);
|
||||
assert_eq!(configs[3].command, "pnpm");
|
||||
assert_eq!(configs[4].command, "python3", "shell configs are left alone");
|
||||
// Django tests run on the selected interpreter, like `manage.py runserver`.
|
||||
assert_eq!(configs[5].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[5].command, python);
|
||||
assert_eq!(configs[5].args, ["manage.py", "test"]);
|
||||
assert_eq!(configs[6].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[6].command, python);
|
||||
assert_eq!(configs[6].args, ["manage.py", "test", "polls"]);
|
||||
}
|
||||
|
||||
@@ -2231,11 +2244,13 @@ fn nested_python_configs_get_their_own_virtualenv_but_explicit_settings_stay_pro
|
||||
apply_to_run_configs(std::slice::from_mut(&mut member), root, &Settings::default());
|
||||
assert_eq!(member.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.python.interpreter = "custom/bin/python".into();
|
||||
settings.languages.python.interpreter = root.join("custom/bin").join(exe("python")).to_string_lossy().into_owned();
|
||||
std::fs::create_dir_all(root.join("custom/bin")).unwrap();
|
||||
std::fs::write(root.join("custom/bin").join(exe("python")), "").unwrap();
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
config.extras.module = Some("apps/python".into());
|
||||
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
assert_eq!(config.command, root.join("custom/bin/python").to_string_lossy());
|
||||
assert_eq!(config.command, root.join("custom/bin").join(exe("python")).to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2248,8 +2263,94 @@ fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() {
|
||||
let mut settings = Settings::default();
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(inner));
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&root.join("apps/other")), &settings), Some(outer));
|
||||
settings.languages.python.interpreter = "selected/python".into();
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python")));
|
||||
let mut settings = Settings::default();
|
||||
let selected = root.join("selected").join(exe("python"));
|
||||
std::fs::create_dir_all(root.join("selected")).unwrap();
|
||||
std::fs::write(&selected, "").unwrap();
|
||||
settings.languages.python.interpreter = selected.to_string_lossy().into_owned();
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(selected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interpreter_that_no_longer_exists_falls_back_to_the_project_virtualenv() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let venv = fake_venv(root, ".venv");
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.python.interpreter = root.join("gone/python").to_string_lossy().into_owned();
|
||||
// Resolution skips the missing executable like PyCharm's "Invalid" SDKs.
|
||||
assert_eq!(crate::apply::python_interpreter(root, &settings), Some(venv.clone()));
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, None, &settings), Some(venv.clone()));
|
||||
// Run configurations use the fallback instead of the dead path.
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
assert_eq!(config.command, venv.to_string_lossy());
|
||||
assert_eq!(config.env["VIRTUAL_ENV"], venv.parent().unwrap().parent().unwrap().to_string_lossy());
|
||||
// A configured interpreter that still exists keeps winning over the venv.
|
||||
let real = root.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
let real_python = real.join(exe("python"));
|
||||
std::fs::write(&real_python, "").unwrap();
|
||||
settings.languages.python.interpreter = real_python.to_string_lossy().into_owned();
|
||||
assert_eq!(crate::apply::python_interpreter(root, &settings), Some(real_python));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_without_python_falls_back_to_the_primary_project_interpreter() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let primary = root.join("primary");
|
||||
let bare = root.join("bare");
|
||||
let outer = fake_venv(&primary, ".venv");
|
||||
// An attached member with a venv of its own uses it.
|
||||
let own = fake_venv(&root.join("own"), ".venv");
|
||||
let settings = Settings::default();
|
||||
assert_eq!(crate::apply::python_interpreter_near(&root.join("own"), Some(&own), &settings), Some(own));
|
||||
|
||||
// A run configuration of the bare member runs on the primary project's venv.
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
config.extras.module = Some("src".into());
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &settings);
|
||||
assert_eq!(config.command, outer.to_string_lossy());
|
||||
assert_eq!(config.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
|
||||
// Saving strips what settings derived, so the fallback keeps following settings.
|
||||
let stored = without_derived_in(&config, &bare, Some(&primary), &settings);
|
||||
assert_eq!(stored.command, exe("python"));
|
||||
assert!(!stored.env.contains_key("VIRTUAL_ENV"));
|
||||
|
||||
// The member's explicit interpreter wins over the primary project's venv —
|
||||
// when it exists (bare names stay bare; a missing path falls back).
|
||||
let mut chosen = Settings::default();
|
||||
chosen.languages.python.interpreter = root.join("bare/python").to_string_lossy().into_owned();
|
||||
std::fs::create_dir_all(&bare).unwrap();
|
||||
let bare_python = bare.join(exe("python"));
|
||||
std::fs::write(&bare_python, "").unwrap();
|
||||
chosen.languages.python.interpreter = bare_python.to_string_lossy().into_owned();
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
config.extras.module = Some("src".into());
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &chosen);
|
||||
assert_eq!(config.command, bare_python.to_string_lossy());
|
||||
assert!(!config.env.contains_key("VIRTUAL_ENV"));
|
||||
|
||||
// An interpreter inherited from the primary follows a later change of its setting
|
||||
// (the paths are created here: resolution skips missing executables).
|
||||
let mut inherited = Settings::default();
|
||||
for dir in ["primary", "moved"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join(exe("python")), "").unwrap();
|
||||
}
|
||||
let primary_python = root.join("primary").join(exe("python"));
|
||||
let moved_python = root.join("moved").join(exe("python"));
|
||||
inherited.languages.python.interpreter = primary_python.to_string_lossy().into_owned();
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
config.extras.module = Some("src".into());
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &inherited);
|
||||
assert_eq!(config.command, primary_python.to_string_lossy());
|
||||
let stored = without_derived_in(&config, &bare, Some(&primary), &inherited);
|
||||
inherited.languages.python.interpreter = moved_python.to_string_lossy().into_owned();
|
||||
let mut reloaded = stored;
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut reloaded), &bare, Some(&primary), &inherited);
|
||||
assert_eq!(reloaded.command, moved_python.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2350,12 +2451,17 @@ fn tool_installs_resolve_project_relative_sdks_and_leave_path_fallback_unconfigu
|
||||
assert!(!defaults.env.contains_key("GOROOT"));
|
||||
s.languages.go.goroot = "go-sdk".into();
|
||||
s.languages.node.interpreter = "node-sdk/bin/node".into();
|
||||
s.languages.python.interpreter = "python-sdk/bin/python3.14".into();
|
||||
s.languages.ruby.interpreter = "ruby-sdk/bin/ruby".into();
|
||||
let runtime = tool_install_environment(root, &s);
|
||||
assert_eq!(runtime.commands["go"], root.join("go-sdk/bin").join(if cfg!(windows) { "go.exe" } else { "go" }));
|
||||
assert_eq!(runtime.env["GOROOT"], root.join("go-sdk").to_string_lossy());
|
||||
assert_eq!(runtime.commands["node"], root.join("node-sdk/bin/node"));
|
||||
assert_eq!(runtime.commands["python"], root.join("python-sdk/bin/python3.14"));
|
||||
assert_eq!(runtime.commands["ruby"], root.join("ruby-sdk/bin/ruby"));
|
||||
// A configured interpreter whose executable is missing is skipped here too:
|
||||
// the tool install environment only names Pythons that can actually run.
|
||||
std::fs::create_dir_all(root.join("python-sdk/bin")).unwrap();
|
||||
let real = root.join("python-sdk/bin").join(exe("python3.14"));
|
||||
std::fs::write(&real, "").unwrap();
|
||||
s.languages.python.interpreter = real.to_string_lossy().into_owned();
|
||||
assert_eq!(tool_install_environment(root, &s).commands["python"], real);
|
||||
}
|
||||
@@ -230,7 +230,14 @@ impl Session {
|
||||
let mut on_output = on_output;
|
||||
tokio::spawn(async move {
|
||||
let mut code = None;
|
||||
while let Some(msg) = read.wait().await {
|
||||
loop {
|
||||
// After the exit status, output still in flight is read until the channel goes
|
||||
// quiet: a child that inherited stdout keeps the channel open past the exit.
|
||||
let msg = match code {
|
||||
None => read.wait().await,
|
||||
Some(_) => tokio::time::timeout(EXIT_DRAIN, read.wait()).await.ok().flatten(),
|
||||
};
|
||||
let Some(msg) = msg else { break };
|
||||
match msg {
|
||||
ChannelMsg::Data { data } => on_output(Output::Stdout(&data)),
|
||||
ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)),
|
||||
@@ -317,6 +324,9 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a process's channel may stay quiet after its exit status before it counts as finished.
|
||||
const EXIT_DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// A chunk of a remote process's output.
|
||||
pub enum Output<'a> {
|
||||
Stdout(&'a [u8]),
|
||||
|
||||
@@ -59,7 +59,8 @@ impl server::Handler for Sshd {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3.
|
||||
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3. `linger` exits but keeps
|
||||
/// the channel open, as when a child it started still holds stdout.
|
||||
async fn exec_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
@@ -70,8 +71,10 @@ impl server::Handler for Sshd {
|
||||
session.data(channel, format!("ran: {}", String::from_utf8_lossy(data)).into_bytes())?;
|
||||
session.extended_data(channel, 1, b"oops".to_vec())?;
|
||||
session.exit_status_request(channel, 3)?;
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
if data != b"linger" {
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -294,6 +297,30 @@ async fn runs_commands_and_streams_processes() {
|
||||
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_process_that_exits_finishes_even_while_its_channel_stays_open() {
|
||||
let env = env().await;
|
||||
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
|
||||
let seen: Arc<Mutex<String>> = Arc::default();
|
||||
let s = seen.clone();
|
||||
let process = session
|
||||
.spawn(
|
||||
"linger",
|
||||
None,
|
||||
Box::new(move |o| {
|
||||
if let Output::Stdout(b) = o {
|
||||
s.lock().push_str(&String::from_utf8_lossy(b));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let code = tokio::time::timeout(std::time::Duration::from_secs(10), process.wait())
|
||||
.await
|
||||
.expect("the exit status must end the process");
|
||||
assert_eq!((code, seen.lock().as_str()), (Some(3), "ran: linger"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_reports_a_command_that_stops_reading() {
|
||||
let env = env().await;
|
||||
|
||||
@@ -407,6 +407,17 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
homepage: "https://github.com/graphql/graphiql",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "prisma-language-server",
|
||||
display: "Prisma Language Server",
|
||||
kind: LanguageServer,
|
||||
commands: &["prisma-language-server"],
|
||||
method: Method::Npm { packages: &["@prisma/language-server"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/prisma/language-tools",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "intelephense",
|
||||
display: "Intelephense (PHP)",
|
||||
|
||||
@@ -783,6 +783,17 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
Ok(self.blob_of(path, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
|
||||
}
|
||||
/// The file's git mode at `rev` as printed by git (`100644`, `100755`, `120000`…),
|
||||
/// `None` when absent there. `rev` follows `file_at`'s rules, `WT` reads the worktree.
|
||||
pub fn file_mode_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
if rev == Some("WT") {
|
||||
return repo::worktree_mode(self.root(), &self.rel_path(path)?);
|
||||
}
|
||||
let rel = self.rel_path(path)?;
|
||||
let repo = self.gix()?;
|
||||
repo::mode_at(&repo, &rel, rev.unwrap_or("HEAD"))
|
||||
}
|
||||
/// Raw revision bytes, for encoding detection when the working file is absent.
|
||||
pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<Vec<u8>>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
|
||||
@@ -255,6 +255,56 @@ fn index_spelling(index: &gix::index::State, rel: &str) -> Option<String> {
|
||||
found
|
||||
}
|
||||
|
||||
/// The mode a worktree file would be committed with (`100755` when executable,
|
||||
/// `100644` otherwise, `120000` for a symlink), `None` when `abs` is not a file.
|
||||
/// On filesystems without an executable bit git can't record one, so it is `100644`.
|
||||
pub(crate) fn worktree_mode(root: &Path, rel: &str) -> IdeResult<Option<String>> {
|
||||
let meta = match std::fs::symlink_metadata(root.join(rel)) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if meta.file_type().is_symlink() {
|
||||
return Ok(Some("120000".to_owned()));
|
||||
}
|
||||
if !meta.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
Ok(Some(if meta.permissions().mode() & 0o111 != 0 { "100755" } else { "100644" }.to_owned()))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
Ok(Some("100644".to_owned()))
|
||||
}
|
||||
|
||||
/// The entry's mode of `rel` at `rev` as git prints it (`100644`, `100755`, `120000`,
|
||||
/// `160000`…), `None` when the path is absent there. Same `rev` rules as [`blob_at`].
|
||||
pub(crate) fn mode_at(repo: &gix::Repository, rel: &str, rev: &str) -> IdeResult<Option<String>> {
|
||||
if let Some(stage) = rev.strip_prefix(':') {
|
||||
let stage = match stage {
|
||||
"" | "0" => gix::index::entry::Stage::Unconflicted,
|
||||
"1" => gix::index::entry::Stage::Base,
|
||||
"2" => gix::index::entry::Stage::Ours,
|
||||
"3" => gix::index::entry::Stage::Theirs,
|
||||
other => return Err(IdeError::invalid(format!("invalid index stage :{other}"))),
|
||||
};
|
||||
let index = repo.index_or_empty().map_err(gx)?;
|
||||
let Some(entry) = index.entry_by_path_and_stage(BStr::new(rel.as_bytes()), stage) else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(entry.mode.to_tree_entry_mode().map(|m| format!("{:o}", m.value())));
|
||||
}
|
||||
let Some(tree) = tree_of(repo, rev)? else { return Ok(None) };
|
||||
let Some(entry) = tree.lookup_entry_by_path(Path::new(rel)).map_err(gx)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !entry.mode().is_blob_or_symlink() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(format!("{:o}", entry.mode().value())))
|
||||
}
|
||||
|
||||
/// Blob content of `rel` at `rev`. `rev` is a revision (`HEAD`, a branch, an id) or
|
||||
/// `:N` for index stage N (`:` / `:0` = the merged index entry).
|
||||
pub(crate) fn blob_at(repo: &gix::Repository, rel: &str, rev: &str) -> IdeResult<Option<Vec<u8>>> {
|
||||
|
||||
@@ -125,6 +125,8 @@ fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
|
||||
ChangeKind::Conflicted
|
||||
}
|
||||
EntryStatus::Change(WtChange::Removed) => ChangeKind::Deleted,
|
||||
// `content_change` is `None` when only the executable bit changed:
|
||||
// still a modification `git status` lists and `git commit` records.
|
||||
EntryStatus::Change(_) => ChangeKind::Modified,
|
||||
EntryStatus::IntentToAdd => ChangeKind::Added,
|
||||
EntryStatus::NeedsUpdate(_) => continue,
|
||||
@@ -402,6 +404,40 @@ mod tests {
|
||||
assert_eq!(from_git.1, ["conflict.txt"]);
|
||||
}
|
||||
|
||||
/// A chmod-only change is a modification `git status` lists and `git commit`
|
||||
/// records, so status keeps it (#2853): hidden, the Commit view could not show
|
||||
/// or commit what a push would carry. `core.fileMode=false` reports nothing.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_mode_only_change_is_reported_only_when_git_tracks_modes() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
for file_mode in ["true", "false"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
git(dir, &["config", "user.name", "Test User"]);
|
||||
git(dir, &["config", "user.email", "[email protected]"]);
|
||||
git(dir, &["config", "commit.gpgsign", "false"]);
|
||||
git(dir, &["config", "core.fileMode", file_mode]);
|
||||
std::fs::write(dir.join("script.sh"), "#!/bin/sh\n").unwrap();
|
||||
commit_all(dir, "base");
|
||||
std::fs::set_permissions(dir.join("script.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let repo = Repo::discover(dir).unwrap().expect("repository found");
|
||||
let status = repo.status().unwrap();
|
||||
let entry = status.iter().find(|f| f.rel_path == "script.sh");
|
||||
if file_mode == "true" {
|
||||
assert_eq!(
|
||||
entry.and_then(|f| f.unstaged),
|
||||
Some(ChangeKind::Modified),
|
||||
"a mode-only change is modified"
|
||||
);
|
||||
} else {
|
||||
assert!(entry.is_none(), "core.fileMode=false hides mode changes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// git answers status when gitoxide can't read a directory; in Restricted Mode that run
|
||||
/// must not execute the repository's filters.
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -235,7 +235,7 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
|
||||
let head_only = req.method == "HEAD";
|
||||
let response = if !matches!(req.method.as_str(), "GET" | "HEAD") {
|
||||
Response::Error(405, "Method Not Allowed")
|
||||
} else if !host_allowed(req.host.as_deref(), root.port) {
|
||||
} else if !host_allowed(req.host.as_deref()) {
|
||||
Response::Error(403, "Forbidden")
|
||||
} else {
|
||||
resolve(root, &req.target).await
|
||||
@@ -274,8 +274,11 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
|
||||
sock.shutdown().await
|
||||
}
|
||||
|
||||
fn host_allowed(host: Option<&str>, port: u16) -> bool {
|
||||
host.is_some_and(|h| h == format!("127.0.0.1:{port}") || h == format!("localhost:{port}"))
|
||||
/// Loopback names only (DNS rebinding). Any port: a remote host's server is reached through
|
||||
/// a forwarded port of the client, and a browser names the port it connected to anyway.
|
||||
fn host_allowed(host: Option<&str>) -> bool {
|
||||
host.and_then(|h| h.rsplit_once(':'))
|
||||
.is_some_and(|(name, port)| matches!(name, "127.0.0.1" | "localhost") && port.parse::<u16>().is_ok())
|
||||
}
|
||||
|
||||
async fn resolve(root: &Root, target: &str) -> Response {
|
||||
@@ -424,6 +427,9 @@ mod tests {
|
||||
let other = url.replacen(token, "0123", 1);
|
||||
assert!(get(&other, None).await.starts_with("HTTP/1.1 404"));
|
||||
assert!(get(&url, Some("evil.example:80")).await.starts_with("HTTP/1.1 403"));
|
||||
assert!(get(&url, Some("127.0.0.1.evil.example:80")).await.starts_with("HTTP/1.1 403"));
|
||||
// Through a forwarded port of a remote client.
|
||||
assert!(get(&url, Some("127.0.0.1:61234")).await.starts_with("HTTP/1.1 200"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -565,6 +565,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_console_log_colors() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = tmp.path().join("cfg");
|
||||
let proj = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
let store = SettingsStore::load(&cfg, Some(&proj));
|
||||
|
||||
let s = store.set(SettingsScope::Global, "console.logColors.error", json!("#ff0000")).unwrap();
|
||||
assert_eq!(s.console.log_colors.error, "#ff0000");
|
||||
assert!(!s.console.log_colors.enabled, "off by default");
|
||||
let s = store.set(SettingsScope::Global, "console.logColors.enabled", json!(true)).unwrap();
|
||||
assert!(s.console.log_colors.enabled);
|
||||
assert_eq!(s.console.log_colors.error, "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_preserves_comments_and_validates() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -156,6 +156,7 @@ Each launch opens a tab in the **Run** tool window (bottom). The toolbar has **R
|
||||
|
||||
- File paths and stack trace frames in the output are links.
|
||||
- Repeated lines can be folded; configure the patterns in **Settings › Build, Execution, Deployment › Console**, along with the buffer size and the console encoding.
|
||||
- Log lines can take the colour of their level (`ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE`); colours the program prints itself are kept. Turn it on and pick the colours in **Settings › Build, Execution, Deployment › Console**.
|
||||
- For test runs the tab shows a test tree next to the output. See [Testing](/en/guide/testing).
|
||||
|
||||
**Settings › Build, Execution, Deployment › Run/Debug** controls whether the tool window opens when a process starts, whether rerun and closing a running tab ask first, and the time limit for a process to exit after Stop.
|
||||
|
||||
@@ -67,7 +67,7 @@ All keys are kebab-case. Only `id` and `name` are required; a definition with no
|
||||
| `formatter` | table | External formatter; see below. |
|
||||
| `scratch-template` | string | Content of a new scratch file of this language. `$END$` marks where the caret goes. |
|
||||
|
||||
Bundled tree-sitter grammars: `bash`, `c`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `elixir`, `gdscript`, `go`, `graphql`, `haskell`, `hcl`, `html`, `java`, `javascript`, `json`, `kotlin`, `lua`, `make`, `markdown`, `nix`, `ocaml`, `ocaml_interface`, `php`, `proto`, `python`, `ruby`, `rust`, `scala`, `swift`, `toml`, `tsx`, `typescript`, `xml`, `yaml`, `zig`. A new grammar has to be compiled into the IDE; a plugin can provide highlighting and structure for a language without a grammar instead (see [Language features](/en/plugins/guides/language-features)).
|
||||
Bundled tree-sitter grammars: `bash`, `c`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `elixir`, `gdscript`, `go`, `graphql`, `haskell`, `hcl`, `html`, `java`, `javascript`, `json`, `kotlin`, `lua`, `make`, `markdown`, `nix`, `ocaml`, `ocaml_interface`, `php`, `prisma`, `proto`, `python`, `ruby`, `rust`, `scala`, `swift`, `toml`, `tsx`, `typescript`, `xml`, `yaml`, `zig`. A new grammar has to be compiled into the IDE; a plugin can provide highlighting and structure for a language without a grammar instead (see [Language features](/en/plugins/guides/language-features)).
|
||||
|
||||
### `[[framework-ui-modes]]`
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ The table lists every built-in language definition. Server and debugger names in
|
||||
| PHP | `php` | `.php`, `.phtml`, `.php3`, `.php4`, `.php5`, … | Yes | Yes | **intelephense** → phpactor + **laravel-lsp** | **php-debug** |
|
||||
| Plain Text | `plaintext` | `.txt`, `.text` | — | — | — | — |
|
||||
| PowerShell | `powershell` | `.ps1`, `.psm1`, `.psd1`, `.pssc`, `.psrc` | Yes | — | powershell-editor-services | — |
|
||||
| Prisma | `prisma` | `.prisma` | Yes | Yes | **prisma-language-server** | — |
|
||||
| Properties | `properties` | `.properties`, `.prefs` | Yes | — | — | — |
|
||||
| Protocol Buffers | `proto` | `.proto` | Yes | Yes | **buf** | — |
|
||||
| Python | `python` | `.py`, `.pyi`, `.pyw` | Yes | Yes | **basedpyright** → **pyright** → **ty** → **pyrefly** + **ruff** | **debugpy** |
|
||||
|
||||
@@ -238,7 +238,7 @@ The full table is `UI_KEYS` in
|
||||
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
|
||||
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
|
||||
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
|
||||
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
|
||||
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
|
||||
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
|
||||
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
|
||||
|
||||
@@ -504,6 +504,7 @@ conventionally go in the theme file, groups marked _editor scheme_ in the scheme
|
||||
| `--fg-editor-selection` | `inherit` | `inherit` |
|
||||
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
|
||||
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
|
||||
| `--bg-search-result` | `#2d543f` | `#ccccff` |
|
||||
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
|
||||
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
|
||||
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
|
||||
|
||||
@@ -152,6 +152,7 @@ Maven 模块在 **自动** 和 **构建(Make)** 时由 jdtls(ECJ)构建
|
||||
|
||||
- 输出里的文件路径和堆栈帧都可以点击跳转。
|
||||
- 重复的行可以折叠。折叠规则、缓冲区大小和控制台编码在 **设置 › 构建、执行、部署 › 控制台** 中配置。
|
||||
- 日志行可按级别着色(`ERROR`、`WARN`、`INFO`、`DEBUG`、`TRACE`);程序自行输出的颜色保持不变。默认关闭,可在 **设置 › 构建、执行、部署 › 控制台** 开启并修改颜色。
|
||||
- 测试运行时,标签页会在输出旁显示测试树,参见[测试](/zh/guide/testing)。
|
||||
|
||||
**设置 › 构建、执行、部署 › 运行/调试** 控制进程启动时是否打开工具窗口、重新运行或关闭正在运行的标签页前是否确认,以及按停止后等待进程退出的时间。
|
||||
|
||||
@@ -67,7 +67,7 @@ install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Lang
|
||||
| `formatter` | 表 | 外部格式化工具,见下文。 |
|
||||
| `scratch-template` | 字符串 | 新建该语言草稿文件时的内容,`$END$` 表示光标位置。 |
|
||||
|
||||
内置的 tree-sitter 语法:`bash`、`c`、`cmake`、`cpp`、`csharp`、`css`、`dart`、`elixir`、`gdscript`、`go`、`graphql`、`haskell`、`hcl`、`html`、`java`、`javascript`、`json`、`kotlin`、`lua`、`make`、`markdown`、`nix`、`ocaml`、`ocaml_interface`、`php`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
|
||||
内置的 tree-sitter 语法:`bash`、`c`、`cmake`、`cpp`、`csharp`、`css`、`dart`、`elixir`、`gdscript`、`go`、`graphql`、`haskell`、`hcl`、`html`、`java`、`javascript`、`json`、`kotlin`、`lua`、`make`、`markdown`、`nix`、`ocaml`、`ocaml_interface`、`php`、`prisma`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
|
||||
|
||||
### `[[framework-ui-modes]]`
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
|
||||
| PHP | `php` | `.php`, `.phtml`, `.php3`, `.php4`, `.php5`, … | 是 | 是 | **intelephense** → phpactor + **laravel-lsp** | **php-debug** |
|
||||
| Plain Text | `plaintext` | `.txt`, `.text` | — | — | — | — |
|
||||
| PowerShell | `powershell` | `.ps1`, `.psm1`, `.psd1`, `.pssc`, `.psrc` | 是 | — | powershell-editor-services | — |
|
||||
| Prisma | `prisma` | `.prisma` | 是 | 是 | **prisma-language-server** | — |
|
||||
| Properties | `properties` | `.properties`, `.prefs` | 是 | — | — | — |
|
||||
| Protocol Buffers | `proto` | `.proto` | 是 | 是 | **buf** | — |
|
||||
| Python | `python` | `.py`, `.pyi`, `.pyw` | 是 | 是 | **basedpyright** → **pyright** → **ty** → **pyrefly** + **ruff** | **debugpy** |
|
||||
|
||||
@@ -210,7 +210,7 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
|
||||
| `--syn-rainbow-1` … `--syn-rainbow-5`(语义高亮) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
|
||||
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES`(效果颜色) |
|
||||
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
|
||||
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
|
||||
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
|
||||
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
|
||||
| `--ansi-*` | `CONSOLE_*_OUTPUT`(`CONSOLE_DARKGRAY_OUTPUT` 为亮黑,`CONSOLE_GRAY_OUTPUT` 为白色) |
|
||||
|
||||
@@ -469,6 +469,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
|
||||
| `--fg-editor-selection` | `inherit` | `inherit` |
|
||||
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
|
||||
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
|
||||
| `--bg-search-result` | `#2d543f` | `#ccccff` |
|
||||
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
|
||||
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
|
||||
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* by `crates/ide-lsp/tests`.
|
||||
*/
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { CompletionItem } from "../src/bindings";
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
@@ -78,6 +79,52 @@ test("completion lists the server's items and inserts the chosen one", async ({
|
||||
expect((await ipcCalls(page, "lsp_completion")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const key of ["Enter", "Tab"]) {
|
||||
for (const prefix of ["", "v"]) {
|
||||
test(`${key} leaves the caret after a field snippet at root.${prefix}`, async ({ workbench: page }, info) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(() => {
|
||||
const item = (label: string): CompletionItem => ({
|
||||
label,
|
||||
labelDetail: null,
|
||||
labelDescription: "int",
|
||||
detail: "int",
|
||||
kind: "field",
|
||||
documentation: null,
|
||||
sortText: null,
|
||||
filterText: null,
|
||||
preselect: label === "val",
|
||||
deprecated: false,
|
||||
insertText: label,
|
||||
isSnippet: true,
|
||||
replaceRange: null,
|
||||
replaceRangeTab: null,
|
||||
additionalEdits: [],
|
||||
commitCharacters: [],
|
||||
command: null,
|
||||
data: null,
|
||||
server: 1,
|
||||
});
|
||||
window.__ipc.handlers.lsp_completion = () => ({ isIncomplete: false, items: [item("val"), item("value")] });
|
||||
});
|
||||
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
|
||||
await page.keyboard.press("End");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type(`int a = root.${prefix}`);
|
||||
await page.keyboard.press("Control+Space");
|
||||
const popup = page.locator(".cm-tooltip-autocomplete");
|
||||
await expect(popup.getByText("val", { exact: true })).toBeVisible();
|
||||
// CodeMirror ignores acceptance during its 75 ms lookup interaction delay.
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press(key);
|
||||
await expect(popup).toBeHidden();
|
||||
await page.screenshot({ path: info.outputPath("accepted-completion.png"), animations: "disabled" });
|
||||
await page.keyboard.type(";");
|
||||
await expect(editor.locator(".cm-activeLine")).toHaveText(/^\s*int a = root\.val;$/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("⌘B goes to the declaration in another file", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
|
||||
@@ -256,3 +256,32 @@ test("single-line selection stays on its own row when font glyphs exceed line he
|
||||
expect(Math.abs(selBox!.y - lineBox!.y)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(selBox!.height - lineBox!.height)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("vertical tabs leave a narrow editor group most of its width", async ({ workbench: page }) => {
|
||||
await page.setViewportSize({ width: 900, height: 600 });
|
||||
await openFromTree(page, "settings.gradle.kts");
|
||||
await page.evaluate(async () => {
|
||||
const projectModule = "/src/platform/project.ts";
|
||||
const schemaModule = "/src/features/settings/model/schema.ts";
|
||||
const controllerModule = "/src/editor/core/controller.ts";
|
||||
const [{ useProject }, { DEFAULT_SETTINGS }, { editorController, useTabs }] = await Promise.all([
|
||||
import(/* @vite-ignore */ projectModule),
|
||||
import(/* @vite-ignore */ schemaModule),
|
||||
import(/* @vite-ignore */ controllerModule),
|
||||
]);
|
||||
const base = useProject.getState().settings ?? DEFAULT_SETTINGS;
|
||||
useProject.setState({
|
||||
settings: { ...base, editor: { ...base.editor, tabs: { ...base.editor.tabs, placement: "left" } } },
|
||||
});
|
||||
editorController().split(useTabs.getState().activePane, "row");
|
||||
});
|
||||
|
||||
const panes = page.locator("[data-pane-id]");
|
||||
await expect(panes).toHaveCount(2);
|
||||
for (const pane of await panes.all()) {
|
||||
const strip = pane.getByRole("tablist", { name: "Editor tabs" });
|
||||
await expect(strip).toHaveAttribute("aria-orientation", "vertical");
|
||||
const [paneBox, contentBox] = await Promise.all([pane.boundingBox(), pane.getByRole("tabpanel").boundingBox()]);
|
||||
expect(contentBox!.width).toBeGreaterThanOrEqual(paneBox!.width * 0.6 - 1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/** Near the window edge CodeMirror caps a hover tooltip's height (#2910). */
|
||||
test("hover sections scroll inside a height-capped tooltip", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const r = await page.evaluate(async () => {
|
||||
const viewMod = await import("/node_modules/.vite/deps/@codemirror_view.js" as string);
|
||||
const stateMod = await import("/node_modules/.vite/deps/@codemirror_state.js" as string);
|
||||
const { lspTheme } = await import("/src/editor/lsp/theme.ts" as string);
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;inset:0;z-index:99999;background:white";
|
||||
document.body.append(host);
|
||||
const view = new viewMod.EditorView({
|
||||
state: stateMod.EditorState.create({ doc: "x", extensions: lspTheme }),
|
||||
parent: host,
|
||||
});
|
||||
const tip = document.createElement("div");
|
||||
tip.className = "cm-tooltip cm-tooltip-hover";
|
||||
tip.style.cssText = "position:absolute;top:10px;left:10px;height:200px";
|
||||
const sec = document.createElement("div");
|
||||
sec.className = "cm-tooltip-section cm-lsp-hover";
|
||||
sec.innerHTML = Array.from({ length: 40 }, (_, i) => `<p>line ${i}</p>`).join("");
|
||||
tip.append(sec);
|
||||
view.dom.append(tip);
|
||||
const t = tip.getBoundingClientRect(),
|
||||
s = sec.getBoundingClientRect();
|
||||
return {
|
||||
tip: Math.round(t.height),
|
||||
secBottom: Math.round(s.bottom),
|
||||
tipBottom: Math.round(t.bottom),
|
||||
scrolls: sec.scrollHeight > sec.clientHeight,
|
||||
};
|
||||
});
|
||||
expect(r.secBottom).toBeLessThanOrEqual(r.tipBottom);
|
||||
expect(r.scrolls).toBe(true);
|
||||
});
|
||||
@@ -105,6 +105,20 @@ test("Find in Files streams matches by file and opens the chosen one at its line
|
||||
});
|
||||
const preview = dialog.getByRole("region", { name: "Preview", exact: true }).locator(".cm-content");
|
||||
await expect(preview).toHaveAttribute("contenteditable", "true");
|
||||
// Matches take the editor scheme's search result background (IDEA's SEARCH_RESULT_ATTRIBUTES) and keep the syntax colours.
|
||||
const match = preview.locator(".cm-openidea-searchResult").first();
|
||||
await expect(match).toHaveText("OrderService");
|
||||
expect(
|
||||
await match.evaluate((el) => {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.backgroundColor = "var(--bg-search-result)";
|
||||
el.parentElement!.append(probe);
|
||||
const expected = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
const own = getComputedStyle(el);
|
||||
return own.backgroundColor === expected && own.color === getComputedStyle(el.parentElement!).color;
|
||||
}),
|
||||
).toBe(true);
|
||||
const originalPreview = await preview.textContent();
|
||||
await preview.focus();
|
||||
await page.keyboard.press("Meta+a");
|
||||
|
||||
@@ -41,3 +41,5 @@ nodePath = ""
|
||||
problems = { shortenToSingleLine = false }
|
||||
workingDirectory = { mode = "auto" }
|
||||
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
|
||||
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
|
||||
codeActionOnSave = { mode = "all", rules = [] }
|
||||
@@ -27,7 +27,7 @@ install-hint = "Install Microsoft java-debug (Settings › Languages & Framework
|
||||
name = "jdtls"
|
||||
command = "jdtls"
|
||||
root-markers = ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "build.xml", ".project"]
|
||||
install-hint = "Install Eclipse JDT Language Server (brew install jdtls, or https://github.com/eclipse-jdtls/eclipse.jdt.ls); requires JDK 21+"
|
||||
install-hint = "Install Eclipse JDT Language Server (brew install jdtls, or https://github.com/eclipse-jdtls/eclipse.jdt.ls); runs on a JDK new enough for the installed release, chosen or downloaded automatically"
|
||||
|
||||
[language-servers.initialization-options]
|
||||
bundles = []
|
||||
|
||||
@@ -75,6 +75,8 @@ nodePath = ""
|
||||
problems = { shortenToSingleLine = false }
|
||||
workingDirectory = { mode = "auto" }
|
||||
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
|
||||
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
|
||||
codeActionOnSave = { mode = "all", rules = [] }
|
||||
|
||||
[debugger]
|
||||
adapter = "js-debug"
|
||||
|
||||
@@ -74,6 +74,8 @@ nodePath = ""
|
||||
problems = { shortenToSingleLine = false }
|
||||
workingDirectory = { mode = "auto" }
|
||||
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
|
||||
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
|
||||
codeActionOnSave = { mode = "all", rules = [] }
|
||||
|
||||
[debugger]
|
||||
adapter = "js-debug"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
id = "prisma"
|
||||
name = "Prisma"
|
||||
extensions = ["prisma"]
|
||||
line-comment = "//"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
tree-sitter = "prisma"
|
||||
|
||||
[[language-servers]]
|
||||
name = "prisma-language-server"
|
||||
command = "prisma-language-server"
|
||||
args = ["--stdio"]
|
||||
root-markers = ["package.json", "schema.prisma"]
|
||||
install-hint = "npm i -g @prisma/language-server"
|
||||
|
||||
# Asked for as section `prisma` before every validation; a missing section crashes the server.
|
||||
[language-servers.settings.prisma]
|
||||
enableDiagnostics = true
|
||||
@@ -43,3 +43,5 @@ nodePath = ""
|
||||
problems = { shortenToSingleLine = false }
|
||||
workingDirectory = { mode = "auto" }
|
||||
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
|
||||
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
|
||||
codeActionOnSave = { mode = "all", rules = [] }
|
||||
@@ -89,6 +89,8 @@ nodePath = ""
|
||||
problems = { shortenToSingleLine = false }
|
||||
workingDirectory = { mode = "auto" }
|
||||
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
|
||||
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
|
||||
codeActionOnSave = { mode = "all", rules = [] }
|
||||
|
||||
[debugger]
|
||||
adapter = "js-debug"
|
||||
|
||||
@@ -74,6 +74,8 @@ nodePath = ""
|
||||
problems = { shortenToSingleLine = false }
|
||||
workingDirectory = { mode = "auto" }
|
||||
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
|
||||
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
|
||||
codeActionOnSave = { mode = "all", rules = [] }
|
||||
|
||||
[debugger]
|
||||
adapter = "js-debug"
|
||||
|
||||
@@ -68,3 +68,5 @@ nodePath = ""
|
||||
problems = { shortenToSingleLine = false }
|
||||
workingDirectory = { mode = "auto" }
|
||||
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
|
||||
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
|
||||
codeActionOnSave = { mode = "all", rules = [] }
|
||||
@@ -89,6 +89,7 @@
|
||||
"--fg-editor-selection": "inherit",
|
||||
"--bg-text-search-result": "#114957",
|
||||
"--bg-text-search-result-current": "#165e70",
|
||||
"--bg-search-result": "#2d543f",
|
||||
"--bg-matched-brace": "#38424f",
|
||||
"--matched-brace-weight": "700",
|
||||
"--bg-matched-tag": "#353e4a",
|
||||
|
||||
@@ -231,7 +231,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../src/
|
||||
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting, rainbow indent guides) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
|
||||
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
|
||||
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
|
||||
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
|
||||
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
|
||||
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
|
||||
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
|
||||
|
||||
@@ -329,7 +329,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
263 themeable tokens: 125 in the UI theme file, 138 in the editor colour scheme file.
|
||||
264 themeable tokens: 125 in the UI theme file, 139 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -509,6 +509,7 @@ a token.
|
||||
| `--fg-editor-selection` | `inherit` | `inherit` |
|
||||
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
|
||||
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
|
||||
| `--bg-search-result` | `#2d543f` | `#ccccff` |
|
||||
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
|
||||
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
|
||||
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
|
||||
|
||||
@@ -219,6 +219,16 @@ describe("IntelliJ theme import", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the search result colour apart from the text search result and the lists' search match", () => {
|
||||
const bg = (c: string) => `<value><option name="BACKGROUND" value="${c}"/></value>`;
|
||||
const s = convertIcls(`<scheme name="Search" parent_scheme="Darcula"><attributes>
|
||||
<option name="SEARCH_RESULT_ATTRIBUTES">${bg("2d543f")}</option>
|
||||
<option name="TEXT_SEARCH_RESULT_ATTRIBUTES">${bg("114957")}</option>
|
||||
</attributes></scheme>`);
|
||||
expect(s.tokens).toMatchObject({ "--bg-search-result": "#2d543f", "--bg-text-search-result": "#114957" });
|
||||
expect(s.tokens).not.toHaveProperty("--bg-search-match");
|
||||
});
|
||||
|
||||
it("produces an installable package", () => {
|
||||
const light = stripJsonComments(themeJson.replace('"dark": true,', ""));
|
||||
const c = convertIntellijTheme(light, icls.replace("Darcula", "Default"), "sea");
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
use crate::state::AppState;
|
||||
use ide_agent::registry::AgentRegistry;
|
||||
use ide_agent::{
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
|
||||
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpServer, McpServerInput, NewAgentTask,
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
|
||||
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
|
||||
McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath, RequestId};
|
||||
use ide_host::ops::agent;
|
||||
@@ -370,7 +371,18 @@ pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: Str
|
||||
agent::changes(&state.host, window.label(), task).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
|
||||
/// The task's uncommitted changes and the commits made since it started.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_grouped_changes(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
) -> IdeResult<AgentChanges> {
|
||||
agent::grouped_changes(&state.host, window.label(), task).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_rollback_changes(
|
||||
@@ -378,8 +390,9 @@ pub async fn agent_rollback_changes(
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
paths: Option<Vec<String>>,
|
||||
view: Option<AgentChangesView>,
|
||||
) -> IdeResult<()> {
|
||||
agent::rollback_changes(&state.host, window.label(), task, paths).await
|
||||
agent::rollback_changes(&state.host, window.label(), task, paths, view).await
|
||||
}
|
||||
|
||||
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
|
||||
@@ -154,6 +154,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
agent::agent_rename,
|
||||
agent::agent_delete,
|
||||
agent::agent_changes,
|
||||
agent::agent_grouped_changes,
|
||||
agent::agent_base_text,
|
||||
agent::agent_rollback_changes,
|
||||
agent::agent_accept_changes,
|
||||
@@ -303,6 +304,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
vcs::vcs_branch_info,
|
||||
vcs::vcs_line_hunks,
|
||||
vcs::vcs_file_at,
|
||||
vcs::vcs_file_mode,
|
||||
vcs::vcs_blame,
|
||||
vcs::vcs_block_history,
|
||||
vcs::vcs_log,
|
||||
@@ -455,6 +457,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
tools::jdk_download_vendors,
|
||||
tools::jdk_download_releases,
|
||||
tools::jdk_download_location,
|
||||
tools::jdtls_required_java,
|
||||
tools::jdk_download,
|
||||
scaffold::scaffold_catalog,
|
||||
scaffold::scaffold_options,
|
||||
|
||||
@@ -111,10 +111,8 @@ pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc<ide_host
|
||||
ide_language::set_external_syntax(Some(host.syntax_provider()));
|
||||
ide_run::set_external_detection(Some(host.run_detection()));
|
||||
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(project_host)))));
|
||||
// Released builds list the official plugins (published with each release) until the user picks another index.
|
||||
if !cfg!(debug_assertions) {
|
||||
host.set_default_market_url(Some(ide_host::plugins::OFFICIAL_MARKET_INDEX.into()));
|
||||
}
|
||||
// The official plugins (published with each release) are listed until the user picks another index.
|
||||
host.set_default_market_url(Some(ide_host::plugins::OFFICIAL_MARKET_INDEX.into()));
|
||||
#[cfg(target_os = "macos")]
|
||||
host.set_secret_store(Arc::new(KeychainSecrets));
|
||||
ide_host::plugins::set_editor_providers(Some(&host));
|
||||
|
||||
@@ -66,6 +66,13 @@ pub fn jdk_download_location(state: State<'_, AppState>) -> IdeResult<ide_core::
|
||||
ide_host::ops::tools::jdk_download_location(&state.host, "")
|
||||
}
|
||||
|
||||
/// The Java version the window's jdtls needs; `None` while it isn't installed or doesn't tell.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn jdtls_required_java(window: Window, state: State<'_, AppState>) -> IdeResult<Option<u32>> {
|
||||
ide_host::ops::tools::jdtls_required_java(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
|
||||
/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable
|
||||
/// with `cancel_request(request)`. The JDK is then detected like an installed one,
|
||||
|
||||
@@ -51,6 +51,19 @@ pub async fn vcs_file_at(
|
||||
ide_host::ops::vcs::vcs_file_at(&state.host, window.label(), path, rev).await
|
||||
}
|
||||
|
||||
/// The file's git mode at `rev` (`100644`, `100755`, `120000`…), `"WT"` for the
|
||||
/// worktree; `None` when absent there. Lets the diff viewer report a mode-only change.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_file_mode(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
rev: Option<String>,
|
||||
) -> IdeResult<Option<String>> {
|
||||
ide_host::ops::vcs::vcs_file_mode(&state.host, window.label(), path, rev).await
|
||||
}
|
||||
|
||||
/// History for Selection: commits that changed `lines` (0-based, inclusive) of `path`'s
|
||||
/// editor text. Cancellable with `cancel_request(request)`.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -17,8 +17,7 @@ pub async fn web_browsers() -> IdeResult<Vec<Browser>> {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn web_url(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult<String> {
|
||||
let root = state.project(window.label()).ok().map(|p| p.root.clone());
|
||||
state.web.url_for(root.as_deref(), &path).await
|
||||
ide_host::ops::web::url(&state.host, window.label(), &path).await
|
||||
}
|
||||
|
||||
/// Opens `url` in `browser` (an id from `web_browsers`), or the default browser.
|
||||
|
||||
@@ -409,7 +409,14 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
sub("statusBarWidgets", "Status Bar Widgets", vec![Entry::Dynamic(DYNAMIC_STATUS_BAR_WIDGETS)]),
|
||||
],
|
||||
),
|
||||
sub("activeEditor", "Active Editor", vec![act("editor.toggleSoftWrap", "Soft-Wrap")]),
|
||||
sub(
|
||||
"activeEditor",
|
||||
"Active Editor",
|
||||
vec![
|
||||
act("editor.toggleSoftWrap", "Soft-Wrap"),
|
||||
act("editor.toggleRenderedDocs", "Render Documentation Comments"),
|
||||
],
|
||||
),
|
||||
SEP,
|
||||
act("code.quickDefinition", "Quick Definition"),
|
||||
act("code.quickTypeDefinition", "Quick Type Definition"),
|
||||
|
||||
@@ -76,6 +76,8 @@ pub struct Connection {
|
||||
/// Remote ports forwarded to this machine (Ports tool window), by host (`None`: the remote
|
||||
/// host itself) and remote port.
|
||||
forwards: Mutex<Vec<(ForwardKey, Forward)>>,
|
||||
/// Ports of the remote built-in web server forwarded here, by remote port.
|
||||
web_forwards: Mutex<Vec<(u16, Forward)>>,
|
||||
}
|
||||
|
||||
/// A forwarded port's host (`None`: the remote host itself) and remote port.
|
||||
@@ -544,6 +546,7 @@ pub async fn remote_connect(
|
||||
next_channel: std::sync::atomic::AtomicU64::new(1),
|
||||
session,
|
||||
forwards: Mutex::new(Vec::new()),
|
||||
web_forwards: Mutex::new(Vec::new()),
|
||||
exec,
|
||||
});
|
||||
if let RemoteTarget::DevContainer { folder, config } = &target {
|
||||
@@ -749,6 +752,34 @@ async fn open_forward(c: &Connection, host: Option<&str>, remote_port: u16) -> I
|
||||
Err(IdeError::unavailable("Forwarding ports is not supported in a WSL project yet"))
|
||||
}
|
||||
|
||||
/// `web_url` answers with the remote host's built-in web server, on its loopback: the URL's
|
||||
/// port is forwarded here. A WSL distribution shares this machine's loopback already.
|
||||
async fn forward_web_url(c: &Connection, url: &str) -> IdeResult<String> {
|
||||
let Some((port, path)) = url.strip_prefix("http://127.0.0.1:").and_then(|rest| rest.split_once('/')) else {
|
||||
return Ok(url.to_owned());
|
||||
};
|
||||
let Ok(remote) = port.parse::<u16>() else { return Ok(url.to_owned()) };
|
||||
let existing = c.web_forwards.lock().iter().find(|(p, _)| *p == remote).map(|(_, f)| f.port());
|
||||
let local = match existing {
|
||||
Some(local) => local,
|
||||
None => {
|
||||
let forward = if let Some(session) = c.session.clone() {
|
||||
Forward::Ssh(ide_ssh::Tunnel::listen_on(session, Some(remote), "127.0.0.1", remote).await?)
|
||||
} else if let Some(exec) = c.exec.clone() {
|
||||
Forward::Container(
|
||||
ide_remote::container_tunnel::Tunnel::listen(exec, remote, "127.0.0.1", remote).await?,
|
||||
)
|
||||
} else {
|
||||
return Ok(url.to_owned());
|
||||
};
|
||||
let local = forward.port();
|
||||
c.web_forwards.lock().push((remote, forward));
|
||||
local
|
||||
}
|
||||
};
|
||||
Ok(format!("http://127.0.0.1:{local}/{path}"))
|
||||
}
|
||||
|
||||
/// Forwards the `forwardPorts` of the dev container's configuration (the local folder's).
|
||||
async fn forward_configured_ports(c: &Connection, folder: &str, config: &str) {
|
||||
let local = std::path::PathBuf::from(folder);
|
||||
@@ -858,8 +889,12 @@ pub async fn remote_invoke(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let web_url = cmd == "web_url";
|
||||
let call = Invoke { window: label, cmd, args };
|
||||
let result = invoke_reply(&c.peer, call, std::time::Duration::from_secs(30)).await?;
|
||||
let mut result = invoke_reply(&c.peer, call, std::time::Duration::from_secs(30)).await?;
|
||||
if web_url && let Some(url) = result.as_str() {
|
||||
result = Value::String(forward_web_url(&c, url).await?);
|
||||
}
|
||||
if let (Some((peers, edit)), Some(version)) =
|
||||
(mirrored, result.as_u64().and_then(|version| u32::try_from(version).ok()))
|
||||
{
|
||||
|
||||
@@ -19,8 +19,6 @@ pub struct AppState {
|
||||
pub recent: RecentProjects,
|
||||
/// Plugins (`ide_host::plugins`), shared by all windows.
|
||||
pub plugins: Arc<ide_host::plugins::PluginHost>,
|
||||
/// The built-in web server (Open In › Browser, the editor's browser bar).
|
||||
pub web: ide_web::WebServer,
|
||||
/// Agents › MCP Server: built-in IDE tools for agents.
|
||||
pub mcp: crate::commands::mcp::McpService,
|
||||
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
|
||||
@@ -108,7 +106,6 @@ impl AppState {
|
||||
askpass: start_askpass(&app),
|
||||
sink: global_sink,
|
||||
plugins: crate::commands::plugin::create_host(&app, &data_dir, &host)?,
|
||||
web: ide_web::WebServer::new(),
|
||||
mcp: Default::default(),
|
||||
remote_dev: crate::remote_dev::Connections::default(),
|
||||
host,
|
||||
|
||||
+83
-5
@@ -214,10 +214,16 @@ export const commands = {
|
||||
agentRename: (task: string, title: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rename", { task, title })),
|
||||
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
|
||||
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
|
||||
/** The task's uncommitted changes and the commits made since it started. */
|
||||
agentGroupedChanges: (task: string) => typedError<AgentChanges, IdeError>(__TAURI_INVOKE("agent_grouped_changes", { task })),
|
||||
/** Content of `path` when the task started (left side of its diff). */
|
||||
agentBaseText: (task: string, path: IpcPath) => typedError<string | null, IdeError>(__TAURI_INVOKE("agent_base_text", { task, path })),
|
||||
/** Rollback: the task's changed files among `paths` (all without) back as they were when it started. */
|
||||
agentRollbackChanges: (task: string, paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths })),
|
||||
/** Rollback: the task's changed files among `paths` (all without) back as `view` compares them. */
|
||||
agentRollbackChanges: (task: string, paths: string[] | null, view:
|
||||
/** Everything since the commit the task started from. */
|
||||
"sinceStart" |
|
||||
/** The uncommitted files against `HEAD`; commits made since the start stay. */
|
||||
"group" | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths, view })),
|
||||
/**
|
||||
* Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
* local checkout, uncommitted; files the checkout changed itself are left alone and reported.
|
||||
@@ -610,6 +616,11 @@ export const commands = {
|
||||
/** Gutter change markers for a file, computed against its current (unsaved) text. */
|
||||
vcsLineHunks: (path: string) => typedError<LineHunk[], IdeError>(__TAURI_INVOKE("vcs_line_hunks", { path })),
|
||||
vcsFileAt: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_at", { path, rev })),
|
||||
/**
|
||||
* The file's git mode at `rev` (`100644`, `100755`, `120000`…), `"WT"` for the
|
||||
* worktree; `None` when absent there. Lets the diff viewer report a mode-only change.
|
||||
*/
|
||||
vcsFileMode: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_mode", { path, rev })),
|
||||
vcsBlame: (path: string, text: string | null, revision: string | null) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path, text, revision })),
|
||||
/**
|
||||
* History for Selection: commits that changed `lines` (0-based, inclusive) of `path`'s
|
||||
@@ -931,6 +942,8 @@ export const commands = {
|
||||
jdkDownloadReleases: (vendor: string, request: RequestId) => typedError<JdkReleases, IdeError>(__TAURI_INVOKE("jdk_download_releases", { vendor, request })),
|
||||
/** Where Download JDK puts JDKs unless told otherwise. */
|
||||
jdkDownloadLocation: () => typedError<IpcPath, IdeError>(__TAURI_INVOKE("jdk_download_location")),
|
||||
/** The Java version the window's jdtls needs; `None` while it isn't installed or doesn't tell. */
|
||||
jdtlsRequiredJava: () => typedError<number | null, IdeError>(__TAURI_INVOKE("jdtls_required_java")),
|
||||
/**
|
||||
* Downloads the latest `vendor` JDK of Java `version` into `location` (default:
|
||||
* [`jdk_download_location`]), streaming progress through `on_event`; cancellable
|
||||
@@ -1730,15 +1743,38 @@ export type AgentCapabilities = {
|
||||
backgroundTasks?: boolean,
|
||||
};
|
||||
|
||||
/** A file the task changed, relative to [`AgentTask::base_commit`]. */
|
||||
/**
|
||||
* A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
|
||||
* grouped view.
|
||||
*/
|
||||
export type AgentChange = {
|
||||
path: IpcPath,
|
||||
relPath: string,
|
||||
kind: ChangeKind,
|
||||
added: number,
|
||||
removed: number,
|
||||
/** Path before a rename; the left side of its diff reads this path. */
|
||||
oldPath: string | null,
|
||||
};
|
||||
|
||||
/** A task's uncommitted files and the commits made since it started. */
|
||||
export type AgentChanges = {
|
||||
/** Files changed against `HEAD`, less those changed before the task started and untouched since. */
|
||||
uncommitted: AgentChange[],
|
||||
/**
|
||||
* Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
|
||||
* for tasks working in the project's own checkout.
|
||||
*/
|
||||
commits: AgentCommit[],
|
||||
};
|
||||
|
||||
/** Which comparison a rollback of the task's changes uses. */
|
||||
export type AgentChangesView =
|
||||
/** Everything since the commit the task started from. */
|
||||
"sinceStart" |
|
||||
/** The uncommitted files against `HEAD`; commits made since the start stay. */
|
||||
"group";
|
||||
|
||||
export type AgentCommand = {
|
||||
/** Without the leading `/`. */
|
||||
name: string,
|
||||
@@ -1753,6 +1789,18 @@ export type AgentCommandReadOnlyReason = "packageRunner" | "registry" | "customA
|
||||
|
||||
export type AgentCommandSource = "command" | "skill";
|
||||
|
||||
/** A commit made while the task ran, with the files it changed against its first parent. */
|
||||
export type AgentCommit = {
|
||||
id: string,
|
||||
shortId: string,
|
||||
summary: string,
|
||||
message: string,
|
||||
timeMs: number | null,
|
||||
/** First parent, or `None` for a root commit. */
|
||||
parent: string | null,
|
||||
files: AgentChange[],
|
||||
};
|
||||
|
||||
/** A setting chosen for a new task, by [`AgentConfigOption::id`]. */
|
||||
export type AgentConfigChoice = {
|
||||
id: string,
|
||||
@@ -3126,6 +3174,8 @@ export type ConsoleSettings = {
|
||||
encoding?: string,
|
||||
/** Consecutive lines containing any of these are folded. */
|
||||
foldPatterns?: string[],
|
||||
/** Log lines take the colour of their level (Run/Debug consoles). */
|
||||
logColors?: LogLevelColors,
|
||||
};
|
||||
|
||||
export type ContainerOp = "start" | "stop" | "restart" | "pause" | "unpause" | "kill";
|
||||
@@ -4311,6 +4361,12 @@ export type EditorAppearanceSettings = {
|
||||
/** Right margin and visual guides from Code Style. */
|
||||
hardWrapGuides?: boolean,
|
||||
intentionBulb?: boolean,
|
||||
/**
|
||||
* Draw doc comments (Javadoc, KDoc, JSDoc, rustdoc) as rendered documentation
|
||||
* instead of their source, as IDEA's option of the same name. View › Active
|
||||
* Editor › Render Documentation Comments overrides this for one editor.
|
||||
*/
|
||||
renderDocComments?: boolean,
|
||||
/** Distraction Free Mode: width of the centred text column, in characters. */
|
||||
distractionFreeWidth?: number,
|
||||
};
|
||||
@@ -6635,6 +6691,20 @@ export type LogDetails = {
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
/**
|
||||
* Per-level colours for console log lines: a line whose level is detected
|
||||
* (`ERROR`, `WARN`, …) takes the level's colour; ANSI colours the program
|
||||
* prints itself win over it. Values are CSS colours, usually scheme tokens.
|
||||
*/
|
||||
export type LogLevelColors = {
|
||||
enabled?: boolean,
|
||||
error?: string,
|
||||
warn?: string,
|
||||
info?: string,
|
||||
debug?: string,
|
||||
trace?: string,
|
||||
};
|
||||
|
||||
export type LogLocation = {
|
||||
dir: IpcPath,
|
||||
/** Current log file, if one was written yet. */
|
||||
@@ -6825,7 +6895,11 @@ export type MavenProject = {
|
||||
profiles: string[],
|
||||
/** Java feature version the project compiles for (`maven.compiler.release`, `java.version`…). */
|
||||
javaVersion: number | null,
|
||||
/** Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`. */
|
||||
/**
|
||||
* Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened
|
||||
* project the search stops at its root; outside it, the search stops before
|
||||
* that root so a linked project keeps its own wrapper.
|
||||
*/
|
||||
command: string,
|
||||
/** Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings. */
|
||||
options: string[],
|
||||
@@ -6839,7 +6913,11 @@ export type MavenSettings = {
|
||||
runnerJre?: string,
|
||||
/** Project-relative root POMs removed from the Maven project model. */
|
||||
unlinkedProjects?: string[],
|
||||
/** Project-relative POMs outside the root reactor added as independent top-level projects. */
|
||||
/**
|
||||
* POMs added as independent top-level projects. Inside the opened project the path is
|
||||
* project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking
|
||||
* does not edit either POM.
|
||||
*/
|
||||
linkedProjects?: string[],
|
||||
/** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */
|
||||
home?: string,
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "./cm/folding";
|
||||
import { reverseLines, sortLines } from "./cm/lines";
|
||||
import { customRegions } from "./cm/regions";
|
||||
import { toggleRenderedViewAtCaret } from "./cm/renderedDocs";
|
||||
import { enclosingHeaders } from "./cm/stickyScroll";
|
||||
import { moveElementLeft, moveElementRight } from "./cm/moveElement";
|
||||
import { caretAnchor, openPopup, updatePopup, useLspPopup, type LspPopup } from "@/editor/lsp/popupStore";
|
||||
@@ -748,6 +749,19 @@ export function registerEditorActions(): () => void {
|
||||
else activeSlot()?.model.toggleSoftWrap();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "editor.toggleRenderedDocs",
|
||||
title: "Render Documentation Comments",
|
||||
category: "View",
|
||||
keywords: ["render documentation comments", "javadoc", "rendered doc", "comments"],
|
||||
when: "textEditorActive",
|
||||
toggled: () => activeSlot()?.model.renderedDocsOn() ?? false,
|
||||
run: () => activeSlot()?.model.toggleRenderedDocs(),
|
||||
},
|
||||
text("editor.toggleRenderedView", "Toggle Rendered View", (v) => toggleRenderedViewAtCaret(v), {
|
||||
category: "View",
|
||||
keywords: ["reader mode", "render documentation comment", "javadoc", "edit comment"],
|
||||
}),
|
||||
text("code.foldAll", "Collapse All", (v) => collapseAll(v), { category: "Folding", icon: "collapse-all" }),
|
||||
text("code.unfoldAll", "Expand All", (v) => expandAll(v), { category: "Folding", icon: "expand-all" }),
|
||||
text("code.collapseRecursively", "Collapse Recursively", (v) => collapseRecursively(v), {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractInlineTags, hasContent, renderDoc, restoreInlineTags, splitBody, stripMarkers } from "./docRender";
|
||||
|
||||
describe("hasContent", () => {
|
||||
it("agrees with renderDoc on which comments render to nothing", () => {
|
||||
const cases: Array<[string, "block" | "line"]> = [
|
||||
["/** */", "block"],
|
||||
["/**\n *\n */", "block"],
|
||||
["/** Books a room. */", "block"],
|
||||
["/**\n * @param id the room\n */", "block"],
|
||||
["/** @hide */", "block"],
|
||||
["///", "line"],
|
||||
["/// Frees a room.", "line"],
|
||||
["//!", "line"],
|
||||
];
|
||||
for (const [text, style] of cases)
|
||||
expect(hasContent(text, style), text).toBe(renderDoc(text, style, (md) => `<p>${md}</p>`) !== null);
|
||||
});
|
||||
});
|
||||
|
||||
/** Stands in for the Markdown renderer: wraps a paragraph, leaves the text alone. */
|
||||
const markdown = (md: string) => `<p>${md}</p>`;
|
||||
|
||||
describe("stripMarkers", () => {
|
||||
it("drops the markers of a block comment", () => {
|
||||
const text = ["/**", " * One.", " * Two.", " */"].join("\n");
|
||||
expect(stripMarkers(text, "block")).toBe("One.\nTwo.");
|
||||
});
|
||||
|
||||
it("keeps the indent that follows the marker, so code blocks survive", () => {
|
||||
const text = ["/**", " * Text:", " *", " * code();", " */"].join("\n");
|
||||
expect(stripMarkers(text, "block")).toBe("Text:\n\n code();");
|
||||
});
|
||||
|
||||
it("handles a one-line comment", () => {
|
||||
expect(stripMarkers("/** Just this. */", "block")).toBe("Just this.");
|
||||
});
|
||||
|
||||
it("does not eat a line that only closes the comment", () => {
|
||||
expect(stripMarkers("/**\n * Text.\n */", "block")).toBe("Text.");
|
||||
});
|
||||
|
||||
it("drops /// and //! of line comments", () => {
|
||||
expect(stripMarkers("/// One.\n/// Two.", "line")).toBe("One.\nTwo.");
|
||||
expect(stripMarkers("//! Crate docs.", "line")).toBe("Crate docs.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitBody", () => {
|
||||
it("separates the description from block tags", () => {
|
||||
const { description, tags } = splitBody("Does a thing.\n\n@param id the id\n@return the thing");
|
||||
expect(description).toBe("Does a thing.");
|
||||
expect(tags).toEqual([
|
||||
{ tag: "param", name: "id", type: null, text: "the id" },
|
||||
{ tag: "return", name: null, type: null, text: "the thing" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets a tag's text wrap onto the following lines", () => {
|
||||
const { tags } = splitBody("@param id the id,\nwhich wraps");
|
||||
expect(tags).toEqual([{ tag: "param", name: "id", type: null, text: "the id,\nwhich wraps" }]);
|
||||
});
|
||||
|
||||
it("leaves an @ inside a sentence alone", () => {
|
||||
const { description, tags } = splitBody("Mail to [email protected] for help.");
|
||||
expect(description).toBe("Mail to [email protected] for help.");
|
||||
expect(tags).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps an unknown tag whole rather than splitting off a name", () => {
|
||||
expect(splitBody("@apiNote Call it twice.").tags).toEqual([
|
||||
{ tag: "apiNote", name: null, type: null, text: "Call it twice." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a line starting with @ inside a code block as code, not as a tag", () => {
|
||||
const pre = splitBody("Use it:\n<pre>\n@Override\nvoid run() {}\n</pre>\nDone.");
|
||||
expect(pre.tags).toEqual([]);
|
||||
expect(pre.description).toBe("Use it:\n<pre>\n@Override\nvoid run() {}\n</pre>\nDone.");
|
||||
const fenced = splitBody("Use it:\n```\n@Test\nfun works() {}\n```\n@param id the id");
|
||||
expect(fenced.description).toContain("@Test");
|
||||
expect(fenced.tags.map((t) => t.tag)).toEqual(["param"]);
|
||||
});
|
||||
|
||||
it("splits off the type of a JSDoc or PHPDoc tag, so it is not taken for the name", () => {
|
||||
expect(splitBody("@param {number} a - the first").tags).toEqual([
|
||||
{ tag: "param", name: "a", type: "number", text: "the first" },
|
||||
]);
|
||||
expect(splitBody("@param {Map<string, {id: number}>} m the map").tags[0]).toMatchObject({
|
||||
name: "m",
|
||||
type: "Map<string, {id: number}>",
|
||||
});
|
||||
expect(splitBody("@param int $a the first").tags).toEqual([
|
||||
{ tag: "param", name: "$a", type: "int", text: "the first" },
|
||||
]);
|
||||
expect(splitBody("@throws {RangeError} when out of range").tags).toEqual([
|
||||
{ tag: "throws", name: "RangeError", type: null, text: "when out of range" },
|
||||
]);
|
||||
expect(splitBody("@returns {string} the name").tags).toEqual([
|
||||
{ tag: "returns", name: null, type: "string", text: "the name" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractInlineTags", () => {
|
||||
it("renders {@code} as escaped code", () => {
|
||||
const { text, html } = extractInlineTags("Use {@code a < b}.");
|
||||
expect(html).toEqual(["<code>a < b</code>"]);
|
||||
expect(restoreInlineTags(text, html)).toBe("Use <code>a < b</code>.");
|
||||
});
|
||||
|
||||
it("keeps a code span out of the Markdown, so a_b_c is not italicised", () => {
|
||||
const { text } = extractInlineTags("{@code a_b_c}");
|
||||
expect(text).not.toContain("a_b_c");
|
||||
});
|
||||
|
||||
it("shows a link's label when it has one, the target otherwise", () => {
|
||||
expect(extractInlineTags("{@link Foo#bar(int) the bar}").html[0]).toContain("the bar");
|
||||
expect(extractInlineTags("{@link Foo#bar(int)}").html[0]).toContain("Foo#bar(int)");
|
||||
});
|
||||
|
||||
it("counts nesting, so a brace inside {@code} does not end it", () => {
|
||||
const { html } = extractInlineTags("{@code Map<K, {V}>} rest");
|
||||
expect(html).toEqual(["<code>Map<K, {V}></code>"]);
|
||||
});
|
||||
|
||||
it("leaves an unclosed tag as written", () => {
|
||||
const text = "{@code never closed";
|
||||
expect(extractInlineTags(text)).toEqual({ text, html: [] });
|
||||
});
|
||||
|
||||
it("drops {@inheritDoc}, which has nothing of its own to show", () => {
|
||||
expect(extractInlineTags("{@inheritDoc}").html).toEqual([""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDoc", () => {
|
||||
it("renders the description and groups the tags into sections", () => {
|
||||
const comment = [
|
||||
"/**",
|
||||
" * Books a room.",
|
||||
" *",
|
||||
" * @param id the room",
|
||||
" * @param when the day",
|
||||
" * @return the booking",
|
||||
" * @throws Busy if it is taken",
|
||||
" */",
|
||||
].join("\n");
|
||||
const doc = renderDoc(comment, "block", markdown);
|
||||
expect(doc?.description).toBe("<p>Books a room.</p>");
|
||||
expect(doc?.sections.map((s) => s.title)).toEqual(["Params", "Returns", "Throws"]);
|
||||
expect(doc?.sections[0]?.rows.map((r) => r.name)).toEqual(["id", "when"]);
|
||||
expect(doc?.sections[2]?.rows[0]?.name).toBe("Busy");
|
||||
});
|
||||
|
||||
it("puts @return and @returns in one section", () => {
|
||||
const doc = renderDoc("/**\n * @return a\n * @returns b\n */", "block", markdown);
|
||||
expect(doc?.sections).toHaveLength(1);
|
||||
expect(doc?.sections[0]?.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows every tag: those it knows under their titles, any other under its own name", () => {
|
||||
const doc = renderDoc(
|
||||
"/**\n * Adds.\n * @example\n * add(1, 2)\n * @property id the id\n * @hide\n * @custom it\n */",
|
||||
"block",
|
||||
(md) => md,
|
||||
);
|
||||
expect(doc?.sections.map((s) => [s.title, s.translate])).toEqual([
|
||||
["Properties", true],
|
||||
["Examples", true],
|
||||
["@hide", false],
|
||||
["@custom", false],
|
||||
]);
|
||||
expect(doc?.sections[1]?.rows[0]?.html).toBe("add(1, 2)");
|
||||
expect(doc?.sections[3]?.rows[0]?.html).toBe("it");
|
||||
});
|
||||
|
||||
it("returns null for a comment with nothing to show", () => {
|
||||
expect(renderDoc("/**\n *\n */", "block", markdown)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a tagless comment", () => {
|
||||
expect(renderDoc("/** Just prose. */", "block", markdown)?.description).toBe("<p>Just prose.</p>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Doc comment → HTML, for rendered doc comments (`renderedDocs.ts`) and anything else that
|
||||
* wants a comment as prose.
|
||||
*
|
||||
* One path serves Javadoc, KDoc, JSDoc, PHPDoc and rustdoc: the body is handed to
|
||||
* the Markdown renderer, which passes the raw HTML of a Javadoc body (`<p>`, `<b>`,
|
||||
* `<code>`) straight through while rendering the Markdown of a KDoc or rustdoc one.
|
||||
* Inline tags (`{@code}`, `{@link}`…) and block tags (`@param`, `@return`…) are
|
||||
* ours to expand, and neither Markdown nor HTML knows them.
|
||||
*
|
||||
* Inline tags become placeholders before the Markdown runs and their HTML goes back
|
||||
* in afterwards: their text is code, not Markdown, and `{@code a_b_c}` must not come
|
||||
* out italicised. The HTML put back is built here from escaped text, so it is safe
|
||||
* although it never passes through the sanitizer.
|
||||
*/
|
||||
|
||||
/** A doc comment's text, by the markers that open it. */
|
||||
export type DocStyle = "block" | "line";
|
||||
|
||||
export interface DocComment {
|
||||
/** Document offset of the first character of the comment. */
|
||||
from: number;
|
||||
/** Document offset just past its last character. */
|
||||
to: number;
|
||||
style: DocStyle;
|
||||
/** The comment verbatim, markers included. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block tags given their own section, in the order IDEA lists them. Named tags start
|
||||
* with what they document (`@param name`, `@throws Type`). Any other tag still shows,
|
||||
* after these, under its own name.
|
||||
*/
|
||||
const BLOCK_SECTIONS: Array<{ tag: string; title: string; named: boolean }> = [
|
||||
{ tag: "param", title: "Params", named: true },
|
||||
{ tag: "tparam", title: "Type params", named: true },
|
||||
{ tag: "template", title: "Type params", named: true },
|
||||
{ tag: "property", title: "Properties", named: true },
|
||||
{ tag: "receiver", title: "Receiver", named: false },
|
||||
{ tag: "constructor", title: "Constructor", named: false },
|
||||
{ tag: "return", title: "Returns", named: false },
|
||||
{ tag: "returns", title: "Returns", named: false },
|
||||
{ tag: "throws", title: "Throws", named: true },
|
||||
{ tag: "exception", title: "Throws", named: true },
|
||||
{ tag: "see", title: "See also", named: false },
|
||||
{ tag: "since", title: "Since", named: false },
|
||||
{ tag: "author", title: "Author", named: false },
|
||||
{ tag: "version", title: "Version", named: false },
|
||||
{ tag: "deprecated", title: "Deprecated", named: false },
|
||||
{ tag: "apiNote", title: "API Note", named: false },
|
||||
{ tag: "implSpec", title: "Implementation Requirements", named: false },
|
||||
{ tag: "implNote", title: "Implementation Note", named: false },
|
||||
{ tag: "example", title: "Examples", named: false },
|
||||
{ tag: "sample", title: "Samples", named: false },
|
||||
];
|
||||
|
||||
const SECTION_BY_TAG = new Map(BLOCK_SECTIONS.map((s) => [s.tag, s]));
|
||||
|
||||
/** `@tag` at the start of a stripped line opens a block tag; `@` inside a sentence does not. */
|
||||
const BLOCK_TAG = /^@(\w+)[ \t]*([\s\S]*)$/;
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment's content without its markers: the opening and closing ones, and the
|
||||
* leading `*` of each line, or the `///` of each line. The indent that follows a
|
||||
* marker is kept, so an indented code block inside the comment still is one.
|
||||
*/
|
||||
export function stripMarkers(text: string, style: DocStyle): string {
|
||||
const lines = text.split("\n");
|
||||
if (style === "line") {
|
||||
return lines.map((l) => l.replace(/^[ \t]*\/\/[/!][ \t]?/, "")).join("\n");
|
||||
}
|
||||
const body = lines.map((line, i) => {
|
||||
let l = line;
|
||||
if (i === 0) l = l.replace(/^[ \t]*\/\*+[ \t]?/, "");
|
||||
if (i === lines.length - 1) l = l.replace(/[ \t]*\*+\/[ \t]*$/, "");
|
||||
if (i > 0) l = l.replace(/^[ \t]*\*(?!\/)[ \t]?/, "");
|
||||
return l;
|
||||
});
|
||||
// A comment opening on its own line leaves a blank first line, and closing on its own a blank last one.
|
||||
while (body.length && !body[0]!.trim()) body.shift();
|
||||
while (body.length && !body.at(-1)!.trim()) body.pop();
|
||||
return body.join("\n");
|
||||
}
|
||||
|
||||
export interface BlockTag {
|
||||
tag: string;
|
||||
/** The first word for `@param name text` and `@throws Type text`; null otherwise. */
|
||||
name: string | null;
|
||||
/** The type a JSDoc (`@param {number} a`) or PHPDoc (`@param int $a`) tag gives; null otherwise. */
|
||||
type: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A leading `{type}` (JSDoc), braces balanced, split from the rest of the tag's text. */
|
||||
function leadingType(text: string): { type: string; rest: string } | null {
|
||||
if (!text.startsWith("{")) return null;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === "{") depth++;
|
||||
else if (text[i] === "}" && --depth === 0) return { type: text.slice(1, i).trim(), rest: text.slice(i + 1).trim() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A tag's text after its name: JSDoc separates the two with ` - `, which is not part of it. */
|
||||
const afterName = (text: string) => text.replace(/^-\s+/, "").trim();
|
||||
|
||||
/** Splits a named tag's text into what it documents, its type when given, and the rest. */
|
||||
function namedTag(tag: string, text: string): Omit<BlockTag, "tag"> {
|
||||
const typed = leadingType(text);
|
||||
if (typed) {
|
||||
// `@throws {Error} when…`: the type is what the tag names.
|
||||
if (tag === "throws" || tag === "exception") {
|
||||
return { name: typed.type, type: null, text: afterName(typed.rest) };
|
||||
}
|
||||
const m = /^(\S+)\s*([\s\S]*)$/.exec(typed.rest);
|
||||
return { name: m?.[1] ?? null, type: typed.type, text: afterName(m?.[2] ?? "") };
|
||||
}
|
||||
// PHPDoc puts the type first and the variable second: `@param int $a`.
|
||||
const php = /^([^\s$]\S*)\s+(\$\S+)\s*([\s\S]*)$/.exec(text);
|
||||
if (php) return { name: php[2]!, type: php[1]!, text: afterName(php[3]!) };
|
||||
const m = /^(\S+)[ \t]*([\s\S]*)$/.exec(text);
|
||||
return { name: m?.[1] ?? null, type: null, text: afterName(m?.[2] ?? "") };
|
||||
}
|
||||
|
||||
/** A code block's opening or closing line: a Markdown fence, or HTML `<pre>`. */
|
||||
const FENCE = /^(```|~~~)/;
|
||||
|
||||
/** How the `<pre>` blocks a line opens and closes change the depth of code it leaves. */
|
||||
function preDepthChange(line: string): number {
|
||||
return (line.match(/<pre[\s>]/gi)?.length ?? 0) - (line.match(/<\/pre>/gi)?.length ?? 0);
|
||||
}
|
||||
|
||||
export interface DocBody {
|
||||
description: string;
|
||||
tags: BlockTag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the stripped body into its description and block tags. A tag's text runs
|
||||
* to the next block tag, so a `@param` may wrap over several lines.
|
||||
*/
|
||||
export function splitBody(body: string): DocBody {
|
||||
const lines = body.split("\n");
|
||||
const description: string[] = [];
|
||||
const tags: BlockTag[] = [];
|
||||
let current: { tag: string; lines: string[] } | null = null;
|
||||
const flush = () => {
|
||||
if (!current) return;
|
||||
const section = SECTION_BY_TAG.get(current.tag);
|
||||
const text = current.lines.join("\n").trim();
|
||||
if (section?.named) {
|
||||
tags.push({ tag: current.tag, ...namedTag(current.tag, text) });
|
||||
} else {
|
||||
// `@returns {string} the name`: JSDoc types the value too.
|
||||
const typed = leadingType(text);
|
||||
tags.push({
|
||||
tag: current.tag,
|
||||
name: null,
|
||||
type: typed?.type ?? null,
|
||||
text: typed ? afterName(typed.rest) : text,
|
||||
});
|
||||
}
|
||||
current = null;
|
||||
};
|
||||
// Inside a code block, a line starting with `@` is code (`@Override`), not a block tag.
|
||||
let fenced = false;
|
||||
let pre = 0;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const m = fenced || pre > 0 ? null : BLOCK_TAG.exec(trimmed);
|
||||
if (FENCE.test(trimmed)) fenced = !fenced;
|
||||
pre = Math.max(0, pre + preDepthChange(line));
|
||||
if (m) {
|
||||
flush();
|
||||
current = { tag: m[1]!, lines: [m[2] ?? ""] };
|
||||
} else if (current) {
|
||||
current.lines.push(line);
|
||||
} else {
|
||||
description.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return { description: description.join("\n").trim(), tags };
|
||||
}
|
||||
|
||||
/** The label an inline tag shows: `{@link Foo#bar(int) the label}` shows the label when it has one. */
|
||||
function linkLabel(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
const space = /\s/.exec(trimmed);
|
||||
if (!space) return trimmed;
|
||||
const target = trimmed.slice(0, space.index);
|
||||
const label = trimmed.slice(space.index).trim();
|
||||
return label || target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces each `{@tag …}` with a placeholder, collecting the HTML it stands for.
|
||||
* Nesting is counted so `{@code Map<K, {@literal V}>}` ends at the right brace.
|
||||
*/
|
||||
export function extractInlineTags(text: string): { text: string; html: string[] } {
|
||||
const html: string[] = [];
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const start = text.indexOf("{@", i);
|
||||
if (start < 0) {
|
||||
out += text.slice(i);
|
||||
break;
|
||||
}
|
||||
const m = /^\{@(\w+)[ \t]*/.exec(text.slice(start));
|
||||
if (!m) {
|
||||
out += text.slice(i, start + 2);
|
||||
i = start + 2;
|
||||
continue;
|
||||
}
|
||||
let depth = 1;
|
||||
let j = start + m[0].length;
|
||||
for (; j < text.length && depth > 0; j++) {
|
||||
if (text[j] === "{") depth++;
|
||||
else if (text[j] === "}") depth--;
|
||||
}
|
||||
if (depth > 0) {
|
||||
// Unclosed: leave it as written rather than swallowing the rest of the comment.
|
||||
out += text.slice(i);
|
||||
break;
|
||||
}
|
||||
out += text.slice(i, start);
|
||||
out += placeholder(html.length);
|
||||
html.push(inlineTagHtml(m[1]!, text.slice(start + m[0].length, j - 1)));
|
||||
i = j;
|
||||
}
|
||||
return { text: out, html };
|
||||
}
|
||||
|
||||
/**
|
||||
* U+E000 is in the private use area: no comment contains it, and Markdown leaves it
|
||||
* alone. The digits are delimited so `…0…` and `…01…` cannot be confused.
|
||||
*/
|
||||
function placeholder(index: number): string {
|
||||
return `${index}`;
|
||||
}
|
||||
|
||||
const PLACEHOLDER = /(\d+)/g;
|
||||
|
||||
function inlineTagHtml(tag: string, body: string): string {
|
||||
switch (tag) {
|
||||
case "code":
|
||||
case "systemProperty":
|
||||
return `<code>${escapeHtml(body)}</code>`;
|
||||
case "literal":
|
||||
return escapeHtml(body);
|
||||
case "link":
|
||||
case "linkplain":
|
||||
return `<code class="cm-openidea-docLink">${escapeHtml(linkLabel(body))}</code>`;
|
||||
case "value":
|
||||
return `<code>${escapeHtml(linkLabel(body))}</code>`;
|
||||
case "inheritDoc":
|
||||
return "";
|
||||
default:
|
||||
return escapeHtml(body);
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts the inline tags' HTML back where their placeholders are. */
|
||||
export function restoreInlineTags(html: string, tags: string[]): string {
|
||||
return html.replace(PLACEHOLDER, (all, index: string) => tags[Number(index)] ?? all);
|
||||
}
|
||||
|
||||
export interface RenderedDoc {
|
||||
/** The description, as HTML. */
|
||||
description: string;
|
||||
/**
|
||||
* One entry per block-tag section: those of `BLOCK_SECTIONS` in its order, then any
|
||||
* other tag in the order it comes, titled by the tag as written (`@hide`), which is
|
||||
* code and is not translated.
|
||||
*/
|
||||
sections: Array<{
|
||||
title: string;
|
||||
translate: boolean;
|
||||
rows: Array<{ name: string | null; type: string | null; html: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `comment` has anything to render, without rendering it: `renderDoc` returns
|
||||
* null exactly when it has neither text nor tags. Cheap enough to ask of every comment
|
||||
* in a file, which rendering is not.
|
||||
*/
|
||||
export function hasContent(comment: string, style: DocStyle): boolean {
|
||||
const { description, tags } = splitBody(stripMarkers(comment, style));
|
||||
return description.trim() !== "" || tags.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `comment` with `markdown`, the Markdown-to-safe-HTML function (so this
|
||||
* module stays free of the parser and its 40 KB). Returns null when the comment
|
||||
* carries nothing to show.
|
||||
*/
|
||||
export function renderDoc(comment: string, style: DocStyle, markdown: (md: string) => string): RenderedDoc | null {
|
||||
const { description, tags } = splitBody(stripMarkers(comment, style));
|
||||
const render = (text: string) => {
|
||||
if (!text.trim()) return "";
|
||||
const extracted = extractInlineTags(text);
|
||||
return restoreInlineTags(markdown(extracted.text), extracted.html);
|
||||
};
|
||||
const descriptionHtml = render(description);
|
||||
const sections: RenderedDoc["sections"] = [];
|
||||
const row = (t: BlockTag) => ({ name: t.name, type: t.type, html: render(t.text) });
|
||||
for (const section of BLOCK_SECTIONS) {
|
||||
const rows = tags.filter((t) => t.tag === section.tag).map(row);
|
||||
if (!rows.length) continue;
|
||||
// `@return` and `@returns` are the same section: extend the one already there.
|
||||
const existing = sections.find((s) => s.translate && s.title === section.title);
|
||||
if (existing) existing.rows.push(...rows);
|
||||
else sections.push({ title: section.title, translate: true, rows });
|
||||
}
|
||||
for (const t of tags) {
|
||||
if (SECTION_BY_TAG.has(t.tag)) continue;
|
||||
const title = `@${t.tag}`;
|
||||
const existing = sections.find((s) => !s.translate && s.title === title);
|
||||
if (existing) existing.rows.push(row(t));
|
||||
else sections.push({ title, translate: false, rows: [row(t)] });
|
||||
}
|
||||
if (!descriptionHtml && !sections.length) return null;
|
||||
return { description: descriptionHtml, sections };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { StreamLanguage, type StreamParser } from "@codemirror/language";
|
||||
import { logWarning } from "./highlight";
|
||||
|
||||
const LEVELS =
|
||||
/** Level vocabulary, shared with the console line colouring (`ui/logColors`). */
|
||||
export const LEVELS =
|
||||
"FATAL|CRITICAL|SEVERE|EMERG|ALERT|ERROR|ERR|WARNING|WARN|NOTICE|INFO|DEBUG|TRACE|VERBOSE|FINEST|FINER|FINE";
|
||||
// Upper case anywhere; any case only where a level is unmistakable: `[warn]`, `<Info>`, `level=error`.
|
||||
const LEVEL_UPPER = new RegExp(`^(?:${LEVELS})\\b`);
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "./editing";
|
||||
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
|
||||
import { enterInDocComment } from "./docComment";
|
||||
import { renderDocComments } from "./renderedDocs";
|
||||
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
|
||||
import { stringClipboard } from "./stringClipboard";
|
||||
import { filterCopiedText } from "./clipboard";
|
||||
@@ -485,6 +486,8 @@ export interface PrefsOptions {
|
||||
softWrap: boolean | undefined;
|
||||
/** Soft wraps toggled for the file, overriding `softWrap` and the file patterns. */
|
||||
wrapOverride?: boolean | null;
|
||||
/** Rendered doc comments toggled for the file, overriding `appearance.renderDocComments`. */
|
||||
renderedDocsOverride?: boolean | null;
|
||||
showWhitespace: boolean | undefined;
|
||||
/** Hard wrap column for this file's language. */
|
||||
hardWrapAt: number;
|
||||
@@ -578,6 +581,7 @@ export function prefsExtension(o: PrefsOptions): Extension {
|
||||
out.push(marginGuides([o.hardWrapAt, ...prefs.codeStyle.visualGuides]));
|
||||
if (prefs.appearance.methodSeparators)
|
||||
out.push(EditorView.editorAttributes.of({ class: "cm-openidea-methodSeparators" }));
|
||||
if (o.renderedDocsOverride ?? prefs.appearance.renderDocComments) out.push(renderDocComments.of(true));
|
||||
const hidden = GUTTER_ICON_GROUPS.filter(
|
||||
(grp) => !prefs.showGutterIcons || prefs.gutterIconsDisabled.includes(grp.id),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { useI18n } from "@/platform/i18n";
|
||||
import { renderDocComments, renderedDocsExtension, renderedRanges, toggleRenderedViewAtCaret } from "./renderedDocs";
|
||||
|
||||
let view: EditorView | null = null;
|
||||
const i18n = useI18n.getState();
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
useI18n.setState(i18n);
|
||||
});
|
||||
|
||||
const DOC = ["/**", " * Books a room.", " * @param n nights", " */", "class Rooms {}"].join("\n");
|
||||
|
||||
function open(reader: boolean, doc = DOC) {
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [java(), renderedDocsExtension, renderDocComments.of(reader)],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
const toggles = (v: EditorView) => [...v.dom.querySelectorAll<HTMLElement>(".cm-openidea-docToggle")];
|
||||
|
||||
/** Moves the mouse over the line of `line` (1-based). jsdom lays nothing out, so the block under it is given. */
|
||||
async function hover(v: EditorView, line: number) {
|
||||
vi.spyOn(v, "lineBlockAtHeight").mockReturnValue(v.lineBlockAt(v.state.doc.line(line).from));
|
||||
await act(async () => fireEvent.mouseMove(v.dom, { clientY: v.documentTop }));
|
||||
}
|
||||
|
||||
const pressed = (v: EditorView) => toggles(v).map((t) => t.getAttribute("aria-pressed"));
|
||||
|
||||
/** Whether the gutter gives the toggles a column: its spacer has their width, else none. */
|
||||
const hasColumn = (v: EditorView) =>
|
||||
[...v.dom.querySelectorAll<HTMLElement>(".cm-openidea-docToggles .cm-gutterElement span")].some(
|
||||
(el) => el.style.width === "14px",
|
||||
);
|
||||
|
||||
/** Opens the file rendering its comments, then brings this one's source back with its pencil. */
|
||||
async function openFlippedToSource() {
|
||||
const v = open(true);
|
||||
await hover(v, 1);
|
||||
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
return v;
|
||||
}
|
||||
|
||||
it("shows the pencil beside a rendered comment, and clicking it brings back the source", async () => {
|
||||
const v = open(true);
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
await hover(v, 1);
|
||||
const [pencil] = toggles(v);
|
||||
// A rendered comment is a block widget: the gutter must still give it its toggle.
|
||||
expect(pencil?.getAttribute("aria-pressed")).toBe("true");
|
||||
await act(async () => fireEvent.mouseDown(pencil!, { button: 0 }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
expect(toggles(v)[0]?.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows the lines icon beside a source comment, and clicking it renders the comment", async () => {
|
||||
const v = await openFlippedToSource();
|
||||
await hover(v, 2);
|
||||
const [lines] = toggles(v);
|
||||
expect(lines?.getAttribute("aria-pressed")).toBe("false");
|
||||
await act(async () => fireEvent.mouseDown(lines!, { button: 0 }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
expect(toggles(v)[0]?.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows a source comment's icon only while the mouse is over it, as IDEA does", async () => {
|
||||
const v = await openFlippedToSource();
|
||||
await act(async () => fireEvent.mouseLeave(v.dom));
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
// The column stays, so the text does not shift as the icon comes and goes.
|
||||
expect(hasColumn(v)).toBe(true);
|
||||
await hover(v, 3);
|
||||
expect(pressed(v)).toEqual(["false"]);
|
||||
await hover(v, 5);
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
await hover(v, 1);
|
||||
await act(async () => fireEvent.mouseLeave(v.dom));
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows a rendered comment's pencil only while the mouse is over it", async () => {
|
||||
const v = open(true);
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
await hover(v, 1);
|
||||
expect(pressed(v)).toEqual(["true"]);
|
||||
await hover(v, 5);
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves the icons to the mouse, not the caret", async () => {
|
||||
const v = await openFlippedToSource();
|
||||
await act(async () => fireEvent.mouseLeave(v.dom));
|
||||
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(2).from + 4 } }));
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("while off, shows the lines icon over a comment, and clicking it renders that one, as IDEA does", async () => {
|
||||
const v = open(false);
|
||||
expect(hasColumn(v)).toBe(true);
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
await hover(v, 2);
|
||||
expect(pressed(v)).toEqual(["false"]);
|
||||
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
await hover(v, 1);
|
||||
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
expect(hasColumn(v)).toBe(true);
|
||||
});
|
||||
|
||||
it("while off, gives a file without doc comments no column, and finds a new one once typing pauses", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const v = open(false, "class Rooms {}");
|
||||
expect(hasColumn(v)).toBe(false);
|
||||
await hover(v, 1);
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
await act(async () => v.dispatch({ changes: { from: 0, insert: "/** Books a room. */\n" } }));
|
||||
// Not scanned on the keystroke: only once typing pauses.
|
||||
expect(hasColumn(v)).toBe(false);
|
||||
await act(async () => void vi.advanceTimersByTime(300));
|
||||
expect(hasColumn(v)).toBe(true);
|
||||
await hover(v, 1);
|
||||
expect(pressed(v)).toEqual(["false"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("while off, Toggle Rendered View renders one comment, and its pencil is the way back", async () => {
|
||||
const v = open(false);
|
||||
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(2).from } }));
|
||||
await act(async () => void toggleRenderedViewAtCaret(v));
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
expect(hasColumn(v)).toBe(true);
|
||||
await hover(v, 1);
|
||||
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
// The file still has its comment, so its column stays for the icon.
|
||||
expect(hasColumn(v)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders an edited comment anew, and an unchanged one as before", async () => {
|
||||
const v = open(true);
|
||||
const text = () => v.dom.querySelector(".cm-openidea-renderedDoc")?.textContent ?? "";
|
||||
expect(text()).toContain("Books a room.");
|
||||
const at = v.state.doc.toString().indexOf("room");
|
||||
await act(async () => v.dispatch({ changes: { from: at, to: at + 4, insert: "suite" } }));
|
||||
expect(text()).toContain("Books a suite.");
|
||||
await act(async () => v.dispatch({ changes: { from: at, to: at + 5, insert: "room" } }));
|
||||
expect(text()).toContain("Books a room.");
|
||||
});
|
||||
|
||||
it("makes the toggle a labelled button with IDEA's icons", async () => {
|
||||
const v = open(true);
|
||||
await hover(v, 1);
|
||||
const [pencil] = toggles(v);
|
||||
expect(pencil?.tagName).toBe("BUTTON");
|
||||
expect(pencil?.getAttribute("aria-label")).toBe("Toggle Rendered View");
|
||||
expect(pencil?.querySelector("svg[data-icon='gutter-javadoc-edit']")).not.toBeNull();
|
||||
await act(async () => fireEvent.mouseDown(pencil!, { button: 0 }));
|
||||
await hover(v, 2);
|
||||
expect(toggles(v)[0]?.querySelector("svg[data-icon='gutter-javadoc-read']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows the source of a rendered comment the caret is put inside, as by Find", async () => {
|
||||
const v = open(true);
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
// Its edges are where arrow keys stop on their way past: still rendered there.
|
||||
await act(async () => v.dispatch({ selection: { anchor: 0 } }));
|
||||
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(4).to } }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
const at = v.state.doc.toString().indexOf("room");
|
||||
await act(async () => v.dispatch({ selection: { anchor: at, head: at + 4 } }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("indents by the code's character width, so a font size change keeps it in line", () => {
|
||||
const v = open(true, ["class A {", "\t/**", "\t * Books.", "\t */", "\tvoid book() {}", "}"].join("\n"));
|
||||
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
|
||||
// A tab is a tab size of columns, not one character.
|
||||
expect(body.style.marginLeft).toContain(`${v.state.tabSize} * var(--openidea-doc-char`);
|
||||
});
|
||||
|
||||
it("forgets a flip whose comment is deleted, so the feature turns off again", async () => {
|
||||
const v = open(
|
||||
false,
|
||||
["/** Books a room. */", "class Rooms {}", "/** Lists the rooms. */", "class List {}"].join("\n"),
|
||||
);
|
||||
await act(async () => v.dispatch({ selection: { anchor: 0 } }));
|
||||
await act(async () => void toggleRenderedViewAtCaret(v));
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
// Delete the rendered comment, its line and all: the other comment stays as source.
|
||||
await act(async () => v.dispatch({ changes: { from: 0, to: v.state.doc.line(2).from } }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
// A flip left behind would keep the feature on: the remaining comment, now where the
|
||||
// deleted one began, would render after its next edit.
|
||||
await act(async () => v.dispatch({ changes: { from: v.state.doc.length, insert: " " } }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flip a comment with nothing to render, nor give it an icon", async () => {
|
||||
const v = open(false, ["/** */", "class Rooms {}"].join("\n"));
|
||||
expect(toggleRenderedViewAtCaret(v)).toBe(false);
|
||||
expect(hasColumn(v)).toBe(false);
|
||||
await hover(v, 1);
|
||||
expect(toggles(v)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("draws the section titles and tooltips again in a new UI language", async () => {
|
||||
const v = open(true);
|
||||
const title = () => v.dom.querySelector(".cm-openidea-renderedDocSections dt")?.textContent;
|
||||
expect(title()).toBe("Params:");
|
||||
await act(async () =>
|
||||
useI18n.setState({ locale: "zh", texts: { Params: "参数", "Toggle Rendered View": "切换渲染视图" } }),
|
||||
);
|
||||
expect(title()).toBe("参数:");
|
||||
await hover(v, 1);
|
||||
expect(toggles(v)[0]?.getAttribute("aria-label")).toBe("切换渲染视图");
|
||||
});
|
||||
|
||||
/** The declarations the editor's theme gives `selector` (ending the rule's selector). */
|
||||
function themeRule(selector: string): CSSStyleDeclaration | null {
|
||||
for (const sheet of document.styleSheets)
|
||||
for (const rule of sheet.cssRules)
|
||||
if (rule instanceof CSSStyleRule && rule.selectorText.split(",").some((s) => s.trim().endsWith(selector)))
|
||||
return rule.style;
|
||||
return null;
|
||||
}
|
||||
|
||||
it("gives the rendered comment no vertical margin, which would push the lines below off their numbers", () => {
|
||||
open(true);
|
||||
// The editor measures a block widget by its border box: a margin is height it never sees.
|
||||
const rule = themeRule(".cm-openidea-renderedDoc")!;
|
||||
expect(rule.marginTop || "0").toMatch(/^0(px)?$/);
|
||||
expect(rule.marginBottom || "0").toMatch(/^0(px)?$/);
|
||||
});
|
||||
|
||||
it("reflows the rendered text, so the source's line breaks do not carry over", () => {
|
||||
open(true);
|
||||
// The content is `white-space: pre`, which the widget inherits unless it says otherwise;
|
||||
// jsdom does not compute inheritance, so the rule itself is what is checked.
|
||||
expect(themeRule(".cm-openidea-renderedDoc")?.whiteSpace).toBe("normal");
|
||||
// Code blocks keep theirs.
|
||||
expect(themeRule(".cm-openidea-renderedDoc pre")?.whiteSpace).toBe("pre");
|
||||
});
|
||||
|
||||
it("gives a {@link} the link colour, over the code colour", () => {
|
||||
open(true);
|
||||
expect(themeRule(".cm-openidea-renderedDoc code.cm-openidea-docLink")?.color).toBe("var(--accent)");
|
||||
});
|
||||
|
||||
it("wraps the rendered text at the visible width, not the longest line's", () => {
|
||||
const v = open(true);
|
||||
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
|
||||
expect(body.style.maxWidth).toContain("var(--openidea-doc-width");
|
||||
});
|
||||
|
||||
it("leaves the comment as it is when its rendered text is clicked", async () => {
|
||||
const v = open(true);
|
||||
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
|
||||
await act(async () => fireEvent.mouseDown(body, { button: 0 }));
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Compartment, EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { ensureSyntaxTree } from "@codemirror/language";
|
||||
import { findLanguageDescription, loadSupport } from "./languages";
|
||||
import {
|
||||
commentAt,
|
||||
docComments,
|
||||
renderCacheSize,
|
||||
renderDocComments,
|
||||
renderedDocsExtension,
|
||||
renderedRanges,
|
||||
toggleRenderedView,
|
||||
} from "./renderedDocs";
|
||||
|
||||
const defaultMode = new Compartment();
|
||||
|
||||
function state(doc: string, { reader = true, language = java(), at = 0 } = {}) {
|
||||
return EditorState.create({
|
||||
doc,
|
||||
selection: EditorSelection.cursor(at),
|
||||
extensions: [language, renderedDocsExtension, defaultMode.of(renderDocComments.of(reader))],
|
||||
});
|
||||
}
|
||||
|
||||
/** The replaced ranges as 1-based `[firstLine, lastLine]` pairs. */
|
||||
function replaced(s: EditorState): Array<[number, number]> {
|
||||
return renderedRanges(s).map(([from, to]) => [s.doc.lineAt(from).number, s.doc.lineAt(to).number]);
|
||||
}
|
||||
|
||||
const JAVA = [
|
||||
"package p;", // 1
|
||||
"", // 2
|
||||
"/**", // 3
|
||||
" * Books a room.", // 4
|
||||
" *", // 5
|
||||
" * @param id the room", // 6
|
||||
" */", // 7
|
||||
"public class Rooms {", // 8
|
||||
" /** Frees a room. */", // 9
|
||||
" public void free() {}", // 10
|
||||
"}", // 11
|
||||
].join("\n");
|
||||
|
||||
const BOOK = JAVA.indexOf("/**");
|
||||
const FREE = JAVA.indexOf("/** Frees");
|
||||
|
||||
describe("docComments", () => {
|
||||
it("finds each Javadoc block and its exact range", () => {
|
||||
const s = state(JAVA);
|
||||
const found = docComments(s);
|
||||
expect(found.map((c) => [s.doc.lineAt(c.from).number, s.doc.lineAt(c.to).number])).toEqual([
|
||||
[3, 7],
|
||||
[9, 9],
|
||||
]);
|
||||
expect(found[0]!.style).toBe("block");
|
||||
});
|
||||
|
||||
it("ignores an ordinary block comment", () => {
|
||||
expect(docComments(state("/* not docs */\nclass A {}"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores an empty block comment that only looks like one", () => {
|
||||
expect(docComments(state("/**/\nclass A {}"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores a doc comment sharing its line with code", () => {
|
||||
expect(docComments(state("int a; /** docs */\n"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("joins a run of /// lines into one comment", () => {
|
||||
const found = docComments(state("/// One.\n/// Two.\nfunction f() {}", { language: javascript() }));
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.style).toBe("line");
|
||||
expect(found[0]!.text).toBe("/// One.\n/// Two.");
|
||||
});
|
||||
|
||||
it("keeps two /// runs apart when a blank line separates them", () => {
|
||||
expect(docComments(state("/// One.\n\n/// Two.\nfunction f() {}", { language: javascript() }))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores a plain // comment", () => {
|
||||
expect(docComments(state("// just a note\nfunction f() {}", { language: javascript() }))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the file's default", () => {
|
||||
it("renders every doc comment when on", () => {
|
||||
expect(replaced(state(JAVA))).toEqual([
|
||||
[3, 7],
|
||||
[9, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders none when off", () => {
|
||||
expect(replaced(state(JAVA, { reader: false }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not depend on the caret: one inside a comment leaves it rendered", () => {
|
||||
expect(replaced(state(JAVA, { at: JAVA.indexOf("Books") }))).toEqual([
|
||||
[3, 7],
|
||||
[9, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not change as the caret moves, so nothing flickers", () => {
|
||||
const s = state(JAVA);
|
||||
const moved = s.update({ selection: EditorSelection.cursor(JAVA.indexOf("public class")) }).state;
|
||||
expect(replaced(moved)).toEqual(replaced(s));
|
||||
});
|
||||
|
||||
it("leaves an empty doc comment as source, having nothing to show", () => {
|
||||
expect(replaced(state("/**\n *\n */\nclass A {}"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders no comment until the editor draws it, so opening a large file stays cheap", () => {
|
||||
const doc = Array.from({ length: 40 }, (_, i) => `/** Lazy comment ${i} of a large file. */\nclass L${i} {}`).join(
|
||||
"\n",
|
||||
);
|
||||
const before = renderCacheSize();
|
||||
const s = state(doc);
|
||||
// Every comment is decided rendered: its source is replaced ...
|
||||
expect(replaced(s)).toHaveLength(40);
|
||||
// ... but none was rendered yet: no editor has drawn them.
|
||||
expect(renderCacheSize()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-comment toggle", () => {
|
||||
it("switches one comment to source and leaves the others rendered", () => {
|
||||
const s = state(JAVA).update({ effects: toggleRenderedView.of(BOOK) }).state;
|
||||
expect(replaced(s)).toEqual([[9, 9]]);
|
||||
});
|
||||
|
||||
it("renders one comment while the file's default is source", () => {
|
||||
const s = state(JAVA, { reader: false }).update({ effects: toggleRenderedView.of(FREE) }).state;
|
||||
expect(replaced(s)).toEqual([[9, 9]]);
|
||||
});
|
||||
|
||||
it("toggling twice restores the default", () => {
|
||||
const s = state(JAVA)
|
||||
.update({ effects: toggleRenderedView.of(BOOK) })
|
||||
.state.update({ effects: toggleRenderedView.of(BOOK) }).state;
|
||||
expect(replaced(s)).toEqual([
|
||||
[3, 7],
|
||||
[9, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows its comment through an edit above it", () => {
|
||||
const s = state(JAVA)
|
||||
.update({ effects: toggleRenderedView.of(BOOK) })
|
||||
.state.update({ changes: { from: 0, insert: "// header\n" } }).state;
|
||||
expect(replaced(s)).toEqual([[10, 10]]);
|
||||
});
|
||||
|
||||
it("is cleared when the whole file switches, so every comment ends up the same", () => {
|
||||
const flipped = state(JAVA).update({ effects: toggleRenderedView.of(BOOK) }).state;
|
||||
const off = flipped.update({ effects: defaultMode.reconfigure(renderDocComments.of(false)) }).state;
|
||||
expect(replaced(off)).toEqual([]);
|
||||
const on = off.update({ effects: defaultMode.reconfigure(renderDocComments.of(true)) }).state;
|
||||
expect(replaced(on)).toEqual([
|
||||
[3, 7],
|
||||
[9, 9],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commentAt", () => {
|
||||
it("finds the comment around the caret", () => {
|
||||
const s = state(JAVA);
|
||||
expect(commentAt(s, JAVA.indexOf("Books"))?.from).toBe(BOOK);
|
||||
});
|
||||
|
||||
it("finds the comment right above the caret's line", () => {
|
||||
const s = state(JAVA);
|
||||
expect(commentAt(s, JAVA.indexOf("public class"))?.from).toBe(BOOK);
|
||||
expect(commentAt(s, JAVA.indexOf("public void"))?.from).toBe(FREE);
|
||||
});
|
||||
|
||||
it("finds nothing on a line no comment documents", () => {
|
||||
expect(commentAt(state(JAVA), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("languages", () => {
|
||||
const BLOCK = "/**\n * Hello.\n * @param a the a\n */\n";
|
||||
const LINES = "/// Hello.\n/// World.\n";
|
||||
|
||||
// Lezer grammars give a comment one node; the line-based modes (Kotlin, Scala, Groovy,
|
||||
// Dart, C#, Swift) give each of its lines one. Both must come out as one comment.
|
||||
it.each([
|
||||
["Java", "A.java", BLOCK + "class A {}\n", [1, 4]],
|
||||
["Kotlin", "A.kt", BLOCK + "class A\n", [1, 4]],
|
||||
["Scala", "A.scala", BLOCK + "class A\n", [1, 4]],
|
||||
["Groovy", "A.groovy", BLOCK + "class A {}\n", [1, 4]],
|
||||
["Dart", "a.dart", BLOCK + "class A {}\n", [1, 4]],
|
||||
["C#", "A.cs", LINES + "class A {}\n", [1, 2]],
|
||||
["JavaScript", "a.js", BLOCK + "function a() {}\n", [1, 4]],
|
||||
["TypeScript", "a.ts", BLOCK + "function a() {}\n", [1, 4]],
|
||||
["PHP", "a.php", "<?php\n" + BLOCK + "function a() {}\n", [2, 5]],
|
||||
["Rust", "a.rs", LINES + "fn a() {}\n", [1, 2]],
|
||||
["C++", "a.cpp", BLOCK + "void a();\n", [1, 4]],
|
||||
["Swift", "a.swift", LINES + "func a() {}\n", [1, 2]],
|
||||
] as const)("finds a whole doc comment in %s", async (name, path, doc, [first, last]) => {
|
||||
const support = await loadSupport(findLanguageDescription(name, `/p/${path}`)!);
|
||||
const s = EditorState.create({ doc, extensions: support! });
|
||||
ensureSyntaxTree(s, s.doc.length, 5000);
|
||||
expect(docComments(s).map((c) => [s.doc.lineAt(c.from).number, s.doc.lineAt(c.to).number])).toEqual([
|
||||
[first, last],
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a line-based comment that is never closed", async () => {
|
||||
const support = await loadSupport(findLanguageDescription("Kotlin", "/p/A.kt")!);
|
||||
const s = EditorState.create({ doc: "/**\n * Hello.\nclass A\n", extensions: support! });
|
||||
ensureSyntaxTree(s, s.doc.length, 5000);
|
||||
expect(docComments(s)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,850 @@
|
||||
/**
|
||||
* Rendered doc comments: drawn as the documentation they are, in place of their
|
||||
* source, as IDEA's "Render documentation comments".
|
||||
*
|
||||
* A rendered comment's lines are replaced by one block widget holding its HTML
|
||||
* (`docRender.ts`). Which comments are rendered is decided in two layers, as in IDEA:
|
||||
*
|
||||
* - The file's default: Settings › Editor › General › Appearance › Render
|
||||
* documentation comments, or what View › Active Editor › Render Documentation
|
||||
* Comments made of it for this file (the document model keeps that override, as it
|
||||
* does the soft-wrap one). It arrives as the `renderDocComments` facet.
|
||||
* - Each comment's own gutter icon (and Toggle Rendered View at the caret), which
|
||||
* flips that one comment away from the default. Changing the default clears them,
|
||||
* so switching the whole file always leaves every comment in the same mode.
|
||||
*
|
||||
* Clicking the rendered text leaves the comment as it is: the text can be selected and
|
||||
* copied like any prose, and the gutter icon is the way to the source. The caret can
|
||||
* only get into a rendered comment some other way (Find, Go to Line, undo), and then,
|
||||
* as in IDEA, the comment shows its source: else its text would be edited unseen.
|
||||
*
|
||||
* As in IDEA, a comment's icon shows while the mouse is over the comment, its source
|
||||
* lines or its rendered block, or over the icon's own place in the gutter. So a file of
|
||||
* comments is not a column of icons.
|
||||
*
|
||||
* Off, which is the default, the icon still shows over a comment, so one comment can be
|
||||
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
|
||||
* comments and none is flipped, its comments are found only once typing pauses
|
||||
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
|
||||
* only if the file has doc comments.
|
||||
*/
|
||||
import {
|
||||
EditorState,
|
||||
Facet,
|
||||
countColumn,
|
||||
MapMode,
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type Extension,
|
||||
type Transaction,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
GutterMarker,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
gutter,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { gutterIconMarkup } from "@/ui/icons";
|
||||
import { emptyGutterSpacer } from "@/editor/api";
|
||||
import { tx, useI18n } from "@/platform/i18n";
|
||||
import { loadMarkdown, markupToHtml } from "@/editor/lsp/markdown";
|
||||
import { hasContent, renderDoc, type DocComment, type DocStyle, type RenderedDoc } from "./docRender";
|
||||
import { LINE_INSET } from "./lineInset";
|
||||
|
||||
/** The file's default: render its doc comments or not. */
|
||||
export const renderDocComments = Facet.define<boolean, boolean>({
|
||||
combine: (values) => values[0] ?? false,
|
||||
});
|
||||
|
||||
/** Flips the comment starting at this position away from the file's default. */
|
||||
export const toggleRenderedView = StateEffect.define<number>();
|
||||
|
||||
/** The mouse is over the comment starting here, or (null) over none. */
|
||||
const hoverDoc = StateEffect.define<number | null>();
|
||||
|
||||
/** Announces that the Markdown parser has loaded and the comments can be rendered again. */
|
||||
const markdownLoaded = StateEffect.define<null>();
|
||||
|
||||
/** Announces a new UI language: titles and tooltips are drawn again in it. */
|
||||
const relabel = StateEffect.define<null>();
|
||||
|
||||
// --- finding the comments -----------------------------------------------------
|
||||
|
||||
/** A doc comment opens with `/**`, except when those are an empty block comment's four characters. */
|
||||
function blockDocAt(text: string): boolean {
|
||||
return text.startsWith("/**") && !text.startsWith("/**/");
|
||||
}
|
||||
|
||||
/** rustdoc and C# `///`, and the `//!` that documents the enclosing item. */
|
||||
function lineDocAt(text: string): boolean {
|
||||
return /^\/\/[/!](?!\/)/.test(text);
|
||||
}
|
||||
|
||||
/** Comment nodes, whatever the grammar calls them: `BlockComment`, `LineComment`, `docComment`, `comment`. */
|
||||
const COMMENT_NODE = /comment/i;
|
||||
|
||||
/** Whether only whitespace lies between two document positions. */
|
||||
function adjoins(state: EditorState, from: number, to: number): boolean {
|
||||
return !state.doc.sliceString(from, to).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The doc comments of `state`, in document order. Only comments that have their lines
|
||||
* to themselves are listed: one sharing a line with code cannot be replaced by a block.
|
||||
*
|
||||
* A comment is assembled from the comment nodes rather than taken as one: a Lezer
|
||||
* grammar (Java, JavaScript, PHP, Rust…) gives a block comment one node, but the
|
||||
* line-based modes (Kotlin, Scala, Groovy, Dart, C#, Swift) give each of its lines a
|
||||
* node of its own. So a `/**` node opens a comment that takes in the nodes after it
|
||||
* until one closes it, and consecutive `///` lines join into one, as they are one doc
|
||||
* block in every language that has them.
|
||||
*/
|
||||
export function docComments(state: EditorState): DocComment[] {
|
||||
const out: DocComment[] = [];
|
||||
/** The block comment being assembled, until a node ends it with the closing marker. */
|
||||
let open: DocComment | null = null;
|
||||
const finish = (comment: DocComment) => {
|
||||
const first = state.doc.lineAt(comment.from);
|
||||
const last = state.doc.lineAt(comment.to);
|
||||
if (first.text.slice(0, comment.from - first.from).trim()) return;
|
||||
if (last.text.slice(comment.to - last.from).trim()) return;
|
||||
comment.text = state.doc.sliceString(comment.from, comment.to);
|
||||
out.push(comment);
|
||||
};
|
||||
syntaxTree(state).iterate({
|
||||
enter(node) {
|
||||
if (!COMMENT_NODE.test(node.name)) return;
|
||||
const text = state.doc.sliceString(node.from, node.to);
|
||||
if (open) {
|
||||
if (adjoins(state, open.to, node.from)) {
|
||||
open.to = node.to;
|
||||
if (text.trimEnd().endsWith("*/")) {
|
||||
finish(open);
|
||||
open = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Something other than comment came first: it was never closed, so it is not one.
|
||||
open = null;
|
||||
}
|
||||
if (blockDocAt(text)) {
|
||||
const comment: DocComment = { from: node.from, to: node.to, style: "block", text };
|
||||
if (text.length > 3 && text.trimEnd().endsWith("*/")) finish(comment);
|
||||
else open = comment;
|
||||
return false;
|
||||
}
|
||||
if (!lineDocAt(text)) return false;
|
||||
const previous = out.at(-1);
|
||||
if (
|
||||
previous?.style === "line" &&
|
||||
state.doc.lineAt(previous.to).number === state.doc.lineAt(node.from).number - 1 &&
|
||||
adjoins(state, previous.to, node.from)
|
||||
) {
|
||||
// The next line of the same run: extend it rather than starting a comment.
|
||||
previous.to = node.to;
|
||||
previous.text = state.doc.sliceString(previous.from, node.to);
|
||||
return false;
|
||||
}
|
||||
finish({ from: node.from, to: node.to, style: "line", text });
|
||||
return false;
|
||||
},
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment Toggle Rendered View acts on from `pos`: the one around it, else the one
|
||||
* right above its line (the documentation of the declaration the caret is on).
|
||||
*/
|
||||
export function commentAt(state: EditorState, pos: number): DocComment | null {
|
||||
const line = state.doc.lineAt(pos).number;
|
||||
for (const comment of docComments(state)) {
|
||||
if (pos >= comment.from && pos <= comment.to) return comment;
|
||||
if (state.doc.lineAt(comment.to).number === line - 1) return comment;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- per-comment flips ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start positions of the comments flipped away from the file's default, mapped through
|
||||
* edits so a flip follows its comment. A position that no longer starts a comment is
|
||||
* simply never matched.
|
||||
*/
|
||||
const flipped = StateField.define<readonly number[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
if (tr.startState.facet(renderDocComments) !== tr.state.facet(renderDocComments)) return [];
|
||||
// A flip whose comment lost its opening character is gone with it; one left behind
|
||||
// would keep the feature active (scanning, its column) with nothing to show.
|
||||
let next = tr.docChanged
|
||||
? value.flatMap((pos) => {
|
||||
const mapped = tr.changes.mapPos(pos, 1, MapMode.TrackAfter);
|
||||
return mapped === null ? [] : [mapped];
|
||||
})
|
||||
: value;
|
||||
for (const e of tr.effects) {
|
||||
if (!e.is(toggleRenderedView)) continue;
|
||||
next = next.includes(e.value) ? next.filter((pos) => pos !== e.value) : [...next, e.value];
|
||||
}
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
/** Whether the file renders its comments, or has one flipped: else there is nothing to find or draw. */
|
||||
function active(state: EditorState): boolean {
|
||||
return state.facet(renderDocComments) || state.field(flipped).length > 0;
|
||||
}
|
||||
|
||||
/** Whether the comment starting at `from` is rendered now. */
|
||||
function isRendered(state: EditorState, from: number): boolean {
|
||||
return state.facet(renderDocComments) !== state.field(flipped).includes(from);
|
||||
}
|
||||
|
||||
// --- the widget ---------------------------------------------------------------
|
||||
|
||||
/** A section's title as shown: translated, unless it is a tag as written (`@hide`). */
|
||||
const sectionTitle = (section: RenderedDoc["sections"][number]) =>
|
||||
section.translate ? tx(section.title) : section.title;
|
||||
|
||||
/** Bumped when the UI language changes: widgets made before draw their titles in the old one. */
|
||||
let labelEpoch = 0;
|
||||
|
||||
/** Whether the Markdown parser has loaded: widgets made before drew their text as plain text. */
|
||||
let markdownIsLoaded = false;
|
||||
|
||||
/** Height of one rendered line, for the estimate of a widget not drawn yet. */
|
||||
const ESTIMATED_LINE_HEIGHT = 20;
|
||||
|
||||
/**
|
||||
* A rendered comment. It keeps the comment's source and renders it only when the editor
|
||||
* draws it, which is when it scrolls into view: opening a file with a thousand comments
|
||||
* renders the dozen on screen. Until then its height is estimated from the source.
|
||||
*/
|
||||
class DocWidget extends WidgetType {
|
||||
/** Everything the DOM depends on, made once: the editor compares widgets on every update. */
|
||||
readonly key: string;
|
||||
|
||||
constructor(
|
||||
readonly style: DocStyle,
|
||||
readonly text: string,
|
||||
/** The comment's indent, in columns. */
|
||||
readonly indent: number,
|
||||
) {
|
||||
super();
|
||||
this.key = `${indent}\u0000${labelEpoch}\u0000${markdownIsLoaded ? 1 : 0}\u0000${style}\u0000${text}`;
|
||||
}
|
||||
|
||||
override eq(other: DocWidget): boolean {
|
||||
return this.key === other.key;
|
||||
}
|
||||
|
||||
/** The source's lines less its opening and closing ones: about what the rendered text takes. */
|
||||
override get estimatedHeight(): number {
|
||||
let lines = 1;
|
||||
for (let i = this.text.indexOf("\n"); i >= 0; i = this.text.indexOf("\n", i + 1)) lines++;
|
||||
return Math.max(1, this.style === "block" ? lines - 2 : lines) * ESTIMATED_LINE_HEIGHT;
|
||||
}
|
||||
|
||||
override toDOM(): HTMLElement {
|
||||
const doc = renderCached(this.style, this.text) ?? { description: "", sections: [] };
|
||||
const titles = doc.sections.map(sectionTitle);
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "cm-openidea-renderedDoc";
|
||||
// In the code's character width, which `docMetrics` keeps current: the DOM stays
|
||||
// right through a font size change or zoom, which does not draw the widget again.
|
||||
const left = `(${this.indent} * var(--openidea-doc-char, 1ch) + ${LINE_INSET}px)`;
|
||||
dom.style.marginLeft = `calc${left}`;
|
||||
// The content is as wide as its longest line; the comment wraps at the visible width instead.
|
||||
dom.style.maxWidth = `calc(var(--openidea-doc-width, 100%) - ${left} - ${DOC_RIGHT_GAP}px)`;
|
||||
dom.setAttribute("role", "note");
|
||||
if (doc.description) {
|
||||
const body = document.createElement("div");
|
||||
body.className = "cm-openidea-renderedDocBody";
|
||||
body.innerHTML = doc.description;
|
||||
dom.appendChild(body);
|
||||
}
|
||||
if (doc.sections.length) {
|
||||
const list = document.createElement("dl");
|
||||
list.className = "cm-openidea-renderedDocSections";
|
||||
doc.sections.forEach((section, s) => {
|
||||
section.rows.forEach((row, i) => {
|
||||
const title = document.createElement("dt");
|
||||
// The section's title heads its first row only, as IDEA lists `Params:`.
|
||||
if (i === 0) title.textContent = `${titles[s]}:`;
|
||||
list.appendChild(title);
|
||||
const value = document.createElement("dd");
|
||||
if (row.name !== null) {
|
||||
const name = document.createElement("code");
|
||||
name.textContent = row.name;
|
||||
value.appendChild(name);
|
||||
}
|
||||
if (row.type !== null) {
|
||||
if (row.name !== null) value.appendChild(document.createTextNode(" "));
|
||||
const type = document.createElement("code");
|
||||
type.className = "cm-openidea-docType";
|
||||
type.textContent = row.type;
|
||||
value.appendChild(type);
|
||||
}
|
||||
if ((row.name !== null || row.type !== null) && row.html) value.appendChild(document.createTextNode(" – "));
|
||||
const text = document.createElement("span");
|
||||
text.className = "cm-openidea-renderedDocText";
|
||||
text.innerHTML = row.html;
|
||||
value.appendChild(text);
|
||||
list.appendChild(value);
|
||||
});
|
||||
});
|
||||
dom.appendChild(list);
|
||||
}
|
||||
return dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor leaves every event inside the widget to the browser: its text selects
|
||||
* and copies like any prose, and a click does not move the caret or switch the mode.
|
||||
*/
|
||||
override ignoreEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- decorations and gutter markers -------------------------------------------
|
||||
|
||||
/**
|
||||
* The comment's mode switch, with IDEA's icons: lines while it is source (to render
|
||||
* it), a pencil while rendered (to edit it). The gutter takes it with the mouse, so the
|
||||
* button is not in the tab order.
|
||||
*/
|
||||
class ToggleMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly rendered: boolean,
|
||||
readonly label: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
override eq(other: GutterMarker): boolean {
|
||||
return other instanceof ToggleMarker && other.rendered === this.rendered && other.label === this.label;
|
||||
}
|
||||
|
||||
override toDOM(): Node {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.tabIndex = -1;
|
||||
button.className = "cm-openidea-docToggle";
|
||||
button.title = this.label;
|
||||
button.setAttribute("aria-label", this.label);
|
||||
button.setAttribute("aria-pressed", String(this.rendered));
|
||||
button.innerHTML = gutterIconMarkup(this.rendered ? "gutter-javadoc-edit" : "gutter-javadoc-read") ?? "";
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
/** The toggle in the UI language of the moment: a new language makes a new marker, which the gutter draws again. */
|
||||
function toggleMarker(rendered: boolean): ToggleMarker {
|
||||
return new ToggleMarker(rendered, tx("Toggle Rendered View"));
|
||||
}
|
||||
|
||||
/** Reserves the toggles' width. Not a `ToggleMarker`: the spacer is in the DOM, hidden, and must not read as a button. */
|
||||
class ToggleSpacer extends GutterMarker {
|
||||
override toDOM(): Node {
|
||||
const el = document.createElement("span");
|
||||
el.style.display = "inline-block";
|
||||
el.style.width = "14px";
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSpacer = new ToggleSpacer();
|
||||
|
||||
/** A doc comment, with where its first line starts and its last line ends, and whether it is rendered now. */
|
||||
type PlacedComment = DocComment & { line: number; end: number; rendered: boolean };
|
||||
|
||||
interface DocsView {
|
||||
decorations: DecorationSet;
|
||||
/** Every doc comment, rendered or not. */
|
||||
comments: ReadonlyArray<PlacedComment>;
|
||||
}
|
||||
|
||||
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
|
||||
|
||||
/**
|
||||
* Rendered comments by their source, shared by every editor, least recently used first.
|
||||
* A widget drawn again (scrolled back into view, or after an edit elsewhere) finds its
|
||||
* comment here instead of rendering it again. Past `RENDER_CACHE_SIZE` the least recently
|
||||
* used is dropped, so a large file does not empty the cache for the next one. Emptied
|
||||
* when the Markdown parser loads: what was rendered before it is plain text.
|
||||
*/
|
||||
const renderCache = new Map<string, RenderedDoc | null>();
|
||||
const RENDER_CACHE_SIZE = 2000;
|
||||
|
||||
function renderCached(style: DocStyle, text: string): RenderedDoc | null {
|
||||
const key = style + "\u0000" + text;
|
||||
let rendered = renderCache.get(key);
|
||||
if (rendered === undefined) {
|
||||
rendered = renderDoc(text, style, (md) => markupToHtml({ kind: "markdown", value: md }));
|
||||
if (renderCache.size >= RENDER_CACHE_SIZE) renderCache.delete(renderCache.keys().next().value!);
|
||||
} else {
|
||||
// Move it to the most recently used end.
|
||||
renderCache.delete(key);
|
||||
}
|
||||
renderCache.set(key, rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a comment has anything to render, by its source. Each edit rebuilds the view
|
||||
* and asks it of every comment in the file; this keeps that to a lookup for all but the
|
||||
* comment being edited.
|
||||
*/
|
||||
const contentCache = new Map<string, boolean>();
|
||||
|
||||
function hasContentCached(style: DocStyle, text: string): boolean {
|
||||
const key = style + "\u0000" + text;
|
||||
let content = contentCache.get(key);
|
||||
if (content === undefined) {
|
||||
content = renderCache.has(key) ? renderCache.get(key) !== null : hasContent(text, style);
|
||||
if (contentCache.size >= RENDER_CACHE_SIZE * 5) contentCache.delete(contentCache.keys().next().value!);
|
||||
contentCache.set(key, content);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Exported for tests: the number of comments rendered and cached. */
|
||||
export function renderCacheSize(): number {
|
||||
return renderCache.size;
|
||||
}
|
||||
|
||||
function build(state: EditorState): DocsView {
|
||||
if (!active(state)) return INACTIVE;
|
||||
const decorations = [];
|
||||
const comments: PlacedComment[] = [];
|
||||
for (const comment of docComments(state)) {
|
||||
const first = state.doc.lineAt(comment.from);
|
||||
const last = state.doc.lineAt(comment.to);
|
||||
// A comment with nothing to render keeps its source, and its icon says so. Telling
|
||||
// which those are does not render anything: the widget renders when it is drawn.
|
||||
const rendered = isRendered(state, comment.from) && hasContentCached(comment.style, comment.text);
|
||||
comments.push({ ...comment, line: first.from, end: last.to, rendered });
|
||||
if (!rendered) continue;
|
||||
const indent = countColumn(first.text.slice(0, first.text.length - first.text.trimStart().length), state.tabSize);
|
||||
decorations.push(
|
||||
Decoration.replace({ widget: new DocWidget(comment.style, comment.text, indent), block: true }).range(
|
||||
first.from,
|
||||
last.to,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { decorations: Decoration.set(decorations), comments };
|
||||
}
|
||||
|
||||
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
|
||||
const hovered = StateField.define<number | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(hoverDoc)) value = e.value;
|
||||
return tr.docChanged && !tr.effects.some((e) => e.is(hoverDoc)) ? null : value;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The comments of a file that renders none, as source, with where their lines start and
|
||||
* end. Those that would render to nothing are left out: they get no icon, and a file of
|
||||
* only those no column.
|
||||
*/
|
||||
function placeComments(state: EditorState): PlacedComment[] {
|
||||
return docComments(state)
|
||||
.filter((c) => hasContentCached(c.style, c.text))
|
||||
.map((c) => ({
|
||||
...c,
|
||||
line: state.doc.lineAt(c.from).from,
|
||||
end: state.doc.lineAt(c.to).to,
|
||||
rendered: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replaces the comments found while the file renders none. */
|
||||
const setPassive = StateEffect.define<readonly PlacedComment[]>();
|
||||
|
||||
/**
|
||||
* The doc comments of a file that renders none, for their icons. Rescanning the file on
|
||||
* each keystroke would make typing in a large file several times slower for a feature
|
||||
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
|
||||
* found are mapped through the edits. Empty while the file renders comments: `docsField`
|
||||
* has them then.
|
||||
*/
|
||||
const passiveDocs = StateField.define<readonly PlacedComment[]>({
|
||||
create: (state) => (active(state) ? [] : placeComments(state)),
|
||||
update(value, tr) {
|
||||
if (active(tr.state)) return value.length ? [] : value;
|
||||
// Switched off: the comments rendered a moment ago are known only to the last scan.
|
||||
if (active(tr.startState)) return placeComments(tr.state);
|
||||
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
|
||||
if (!tr.docChanged) return value;
|
||||
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
|
||||
return value.map((c) => ({
|
||||
...c,
|
||||
from: map(c.from, 1),
|
||||
to: map(c.to, -1),
|
||||
line: map(c.line, 1),
|
||||
end: map(c.end, -1),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
/** Scans a file that renders no comments once the edits and the parser have paused. */
|
||||
const passiveScanner = ViewPlugin.fromClass(
|
||||
class {
|
||||
timer: ReturnType<typeof setTimeout> | null = null;
|
||||
constructor(readonly view: EditorView) {}
|
||||
update(update: ViewUpdate) {
|
||||
if (active(update.state)) return this.cancel();
|
||||
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
|
||||
this.cancel();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
|
||||
}, PASSIVE_SCAN_DELAY);
|
||||
}
|
||||
}
|
||||
cancel() {
|
||||
if (this.timer !== null) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
destroy() {
|
||||
this.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
|
||||
const PASSIVE_SCAN_DELAY = 300;
|
||||
|
||||
/** Every doc comment of the file, as last found. */
|
||||
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
|
||||
return active(state) ? state.field(docsField).comments : state.field(passiveDocs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The source-mode toggle, beside the source comment the mouse is over, if it is over one
|
||||
* that has something to render: one that would render to nothing stays source anyway.
|
||||
*/
|
||||
function sourceToggle(state: EditorState): RangeSet<GutterMarker> {
|
||||
const at = state.field(hovered);
|
||||
const comment = at === null ? null : placedComments(state).find((c) => c.from === at && !c.rendered);
|
||||
if (!comment || (!active(state) && !hasContentCached(comment.style, comment.text))) return RangeSet.empty;
|
||||
return RangeSet.of([toggleMarker(false).range(comment.line)]);
|
||||
}
|
||||
|
||||
/** The comment whose lines, source or rendered, make up part of the line block `[from, to]`. */
|
||||
function commentOverlapping(state: EditorState, from: number, to: number): number | null {
|
||||
return placedComments(state).find((c) => from <= c.to && to >= c.line)?.from ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the mouse over the editor, text and gutters alike, by the line block under it,
|
||||
* so a comment's icon shows wherever on its lines the mouse is.
|
||||
*/
|
||||
const hoverTracker = ViewPlugin.fromClass(
|
||||
class {
|
||||
constructor(readonly view: EditorView) {
|
||||
view.dom.addEventListener("mousemove", this.move);
|
||||
view.dom.addEventListener("mouseleave", this.leave);
|
||||
}
|
||||
move = (event: MouseEvent) => {
|
||||
const { view } = this;
|
||||
if (!placedComments(view.state).length) return;
|
||||
const block = view.lineBlockAtHeight(event.clientY - view.documentTop);
|
||||
const inside = event.clientY - view.documentTop <= block.bottom && event.clientY >= view.documentTop;
|
||||
this.set(inside ? commentOverlapping(view.state, block.from, block.to) : null);
|
||||
};
|
||||
leave = () => this.set(null);
|
||||
set(at: number | null) {
|
||||
if (this.view.state.field(hovered) !== at) this.view.dispatch({ effects: hoverDoc.of(at) });
|
||||
}
|
||||
destroy() {
|
||||
this.view.dom.removeEventListener("mousemove", this.move);
|
||||
this.view.dom.removeEventListener("mouseleave", this.leave);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** The comment ranges rendered in place of their source, as `[from, to]` offsets. Exported for tests. */
|
||||
export function renderedRanges(state: EditorState): Array<[number, number]> {
|
||||
const out: Array<[number, number]> = [];
|
||||
state.field(docsField).decorations.between(0, state.doc.length, (from, to) => void out.push([from, to]));
|
||||
return out;
|
||||
}
|
||||
|
||||
const docsField = StateField.define<DocsView>({
|
||||
create: build,
|
||||
update(value, tr) {
|
||||
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
|
||||
const rebuild =
|
||||
tr.docChanged ||
|
||||
tr.reconfigured ||
|
||||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
|
||||
// The parser finishing more of the file can bring comments it had not reached.
|
||||
syntaxTree(tr.startState) !== syntaxTree(tr.state);
|
||||
return rebuild ? build(tr.state) : value;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
|
||||
});
|
||||
|
||||
const toggleGutter = gutter({
|
||||
class: "cm-openidea-docToggles",
|
||||
// Comments left as source: their first line is a text line, which reads these markers.
|
||||
// Asked again on every update, so the icon follows the mouse.
|
||||
markers: (view) => sourceToggle(view.state),
|
||||
// Rendered comments: a block widget is not a text line, and the gutter never looks at
|
||||
// `markers` for one, only at this.
|
||||
widgetMarker: (view, widget, block) =>
|
||||
widget instanceof DocWidget && view.state.field(hovered) === commentOverlapping(view.state, block.from, block.to)
|
||||
? toggleMarker(true)
|
||||
: null,
|
||||
// The widget markers are asked for only when the gutter redraws, which it must when the
|
||||
// mouse moves on, or the language changes.
|
||||
lineMarkerChange: (update) =>
|
||||
update.startState.field(hovered) !== update.state.field(hovered) ||
|
||||
update.transactions.some((tr) => tr.effects.some((e) => e.is(relabel))),
|
||||
// A file without doc comments gets no column at all; one with them keeps its column
|
||||
// while the icon comes and goes, so the text does not shift.
|
||||
initialSpacer: (view) => (placedComments(view.state).length ? toggleSpacer : emptyGutterSpacer),
|
||||
updateSpacer: (_spacer, update) => (placedComments(update.state).length ? toggleSpacer : emptyGutterSpacer),
|
||||
domEventHandlers: {
|
||||
mousedown(view, block, event) {
|
||||
const target = event.target instanceof Element ? event.target.closest(".cm-openidea-docToggle") : null;
|
||||
if ((event as MouseEvent).button !== 0 || !target) return false;
|
||||
// A file that renders none may not have been scanned since its last edit: the flip
|
||||
// must land on where its comment starts now.
|
||||
const comment = active(view.state)
|
||||
? commentOverlapping(view.state, block.from, block.to)
|
||||
: (placeComments(view.state).find((c) => block.from <= c.to && block.to >= c.line)?.from ?? null);
|
||||
if (comment === null) return false;
|
||||
event.preventDefault();
|
||||
view.dispatch({ effects: toggleRenderedView.of(comment) });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Until the Markdown parser has loaded, a comment renders as plain text. It loads the
|
||||
* first time the editor renders anything, so an editor that never does never loads it,
|
||||
* and each that does waits for it once and then rebuilds.
|
||||
*/
|
||||
const markdownReady = ViewPlugin.fromClass(
|
||||
class {
|
||||
alive = true;
|
||||
started = false;
|
||||
constructor(readonly view: EditorView) {
|
||||
this.start();
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (!this.started && (update.docChanged || update.transactions.length)) this.start();
|
||||
}
|
||||
start() {
|
||||
if (!active(this.view.state)) return;
|
||||
this.started = true;
|
||||
void loadMarkdown()
|
||||
.then(() => {
|
||||
markdownIsLoaded = true;
|
||||
if (this.alive) this.view.dispatch({ effects: markdownLoaded.of(null) });
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
destroy() {
|
||||
this.alive = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* A selection that ends inside a rendered comment shows that comment's source. A click
|
||||
* cannot put it there (the widget keeps its events); Find, Go to Line or undo can, and
|
||||
* the text they land on must be visible to be edited. The edges are outside: arrow keys
|
||||
* stop there on their way past.
|
||||
*/
|
||||
const showSourceAtCaret = EditorState.transactionExtender.of((tr: Transaction) => {
|
||||
if (!tr.selection) return null;
|
||||
const docs = tr.startState.field(docsField, false);
|
||||
if (!docs?.comments.length) return null;
|
||||
const map = (pos: number, assoc: number) => (tr.docChanged ? tr.changes.mapPos(pos, assoc) : pos);
|
||||
const effects = [];
|
||||
for (const c of docs.comments) {
|
||||
if (!c.rendered) continue;
|
||||
const from = map(c.line, 1);
|
||||
const to = map(c.end, -1);
|
||||
if (tr.selection.ranges.some((r) => r.head > from && r.head < to))
|
||||
effects.push(toggleRenderedView.of(map(c.from, 1)));
|
||||
}
|
||||
return effects.length ? { effects } : null;
|
||||
});
|
||||
|
||||
/** Draws titles and tooltips again when the UI language changes. */
|
||||
const languageWatcher = ViewPlugin.fromClass(
|
||||
class {
|
||||
readonly unsubscribe: () => void;
|
||||
constructor(view: EditorView) {
|
||||
this.unsubscribe = useI18n.subscribe((next, previous) => {
|
||||
if (next.locale !== previous.locale || next.texts !== previous.texts) {
|
||||
labelEpoch++;
|
||||
view.dispatch({ effects: relabel.of(null) });
|
||||
}
|
||||
});
|
||||
}
|
||||
destroy() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Toggle Rendered View at the caret: the comment around it or right above its line. */
|
||||
export function toggleRenderedViewAtCaret(view: EditorView): boolean {
|
||||
const comment = commentAt(view.state, view.state.selection.main.head);
|
||||
if (!comment) return false;
|
||||
// One with nothing to render would stay source, and its flip would linger to no end.
|
||||
if (!isRendered(view.state, comment.from) && !hasContentCached(comment.style, comment.text)) return false;
|
||||
view.dispatch({ effects: toggleRenderedView.of(comment.from) });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours come from the colour scheme's doc comment attributes, as IDEA's rendered view
|
||||
* takes them: the text is the comment's colour (`--syn-doc-comment`) and code and names
|
||||
* are a tag value's (`--syn-doc-comment-tag-value`). So the rendered comment reads like
|
||||
* the source one, and follows the theme and any Color Scheme edit.
|
||||
*/
|
||||
/** Space kept right of a rendered comment, before the visible edge. */
|
||||
const DOC_RIGHT_GAP = 24;
|
||||
|
||||
/**
|
||||
* Publishes on the content the visible width of the text area, `--openidea-doc-width`,
|
||||
* and the code's character width, `--openidea-doc-char`. A block widget is as wide as
|
||||
* the content, which without soft wraps is as wide as the longest line, so a rendered
|
||||
* comment needs the first to wrap where the reader can see, as IDEA's does; it indents
|
||||
* by the second, which a font size change or zoom changes. Horizontal scrolling leaves
|
||||
* both unchanged.
|
||||
*/
|
||||
const docMetrics = ViewPlugin.fromClass(
|
||||
class {
|
||||
constructor(readonly view: EditorView) {
|
||||
this.measure();
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (update.geometryChanged) this.measure();
|
||||
}
|
||||
measure() {
|
||||
this.view.requestMeasure({
|
||||
key: this,
|
||||
read: (view) => ({
|
||||
width: view.scrollDOM.clientWidth - view.contentDOM.offsetLeft,
|
||||
char: view.defaultCharacterWidth,
|
||||
}),
|
||||
write: ({ width, char }, view) => {
|
||||
const style = view.contentDOM.style;
|
||||
const widthValue = `${Math.max(0, Math.floor(width))}px`;
|
||||
if (style.getPropertyValue("--openidea-doc-width") !== widthValue)
|
||||
style.setProperty("--openidea-doc-width", widthValue);
|
||||
const charValue = `${char}px`;
|
||||
if (style.getPropertyValue("--openidea-doc-char") !== charValue)
|
||||
style.setProperty("--openidea-doc-char", charValue);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const docsTheme = EditorView.theme({
|
||||
".cm-openidea-renderedDoc": {
|
||||
// No vertical margin: the editor measures a block widget by its border box, so a
|
||||
// margin would push every line below it away from its line number.
|
||||
margin: "0",
|
||||
// The content keeps its whitespace (`pre`); prose reflows instead, so the comment's
|
||||
// own line breaks do not carry over and its paragraphs wrap at the visible width.
|
||||
whiteSpace: "normal",
|
||||
overflowWrap: "break-word",
|
||||
padding: "4px 0 6px 12px",
|
||||
borderLeft: "2px solid var(--border)",
|
||||
color: "var(--syn-doc-comment)",
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: "var(--fs-ui)",
|
||||
lineHeight: "1.55",
|
||||
cursor: "auto",
|
||||
WebkitUserSelect: "text",
|
||||
userSelect: "text",
|
||||
},
|
||||
".cm-openidea-renderedDoc p": { margin: "0 0 6px" },
|
||||
".cm-openidea-renderedDoc p:last-child": { marginBottom: "0" },
|
||||
".cm-openidea-renderedDoc code": {
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "0.92em",
|
||||
color: "var(--syn-doc-comment-tag-value)",
|
||||
padding: "0 4px",
|
||||
borderRadius: "var(--radius-small)",
|
||||
background: "var(--bg-elev)",
|
||||
border: "1px solid var(--border)",
|
||||
},
|
||||
".cm-openidea-renderedDoc pre": {
|
||||
margin: "0 0 6px",
|
||||
whiteSpace: "pre",
|
||||
padding: "6px 8px",
|
||||
overflowX: "auto",
|
||||
borderRadius: "var(--radius-small)",
|
||||
background: "var(--bg-elev)",
|
||||
},
|
||||
".cm-openidea-renderedDoc pre code": { padding: "0", background: "none", border: "none" },
|
||||
".cm-openidea-renderedDoc ul, .cm-openidea-renderedDoc ol": { margin: "0 0 6px", paddingLeft: "20px" },
|
||||
// As specific as the code rule it must beat, or a link takes the plain code colour.
|
||||
".cm-openidea-renderedDoc code.cm-openidea-docLink": { color: "var(--accent)" },
|
||||
".cm-openidea-renderedDocSections": {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content 1fr",
|
||||
gap: "2px 8px",
|
||||
margin: "8px 0 0",
|
||||
},
|
||||
".cm-openidea-renderedDoc code.cm-openidea-docType": { color: "var(--syn-doc-comment)" },
|
||||
".cm-openidea-renderedDocSections dt": { color: "var(--syn-doc-comment)" },
|
||||
".cm-openidea-renderedDocSections dd": { margin: "0" },
|
||||
".cm-openidea-renderedDocText p": { display: "inline", margin: "0" },
|
||||
".cm-openidea-docToggles .cm-gutterElement": {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
},
|
||||
".cm-openidea-docToggle": {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
height: "var(--openidea-editor-line-height, var(--lh-code))",
|
||||
padding: "0",
|
||||
border: "none",
|
||||
background: "none",
|
||||
color: "var(--fg-muted)",
|
||||
cursor: "pointer",
|
||||
},
|
||||
".cm-openidea-docToggle:hover": { color: "var(--fg-strong)" },
|
||||
});
|
||||
|
||||
/** Installed always; a file without doc comments gets no column and no decorations. */
|
||||
export const renderedDocsExtension: Extension = [
|
||||
flipped,
|
||||
docsField,
|
||||
passiveDocs,
|
||||
passiveScanner,
|
||||
hovered,
|
||||
hoverTracker,
|
||||
showSourceAtCaret,
|
||||
toggleGutter,
|
||||
markdownReady,
|
||||
languageWatcher,
|
||||
docMetrics,
|
||||
docsTheme,
|
||||
];
|
||||
@@ -42,6 +42,7 @@ import { rainbowBrackets } from "./rainbowBrackets";
|
||||
import { stickyMaxLines, stickyScroll } from "./stickyScroll";
|
||||
import { findExtension } from "./find";
|
||||
import { codeLensExtension } from "./codeLens";
|
||||
import { renderedDocsExtension } from "./renderedDocs";
|
||||
import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapActive } from "./prefs";
|
||||
import { commentTokens } from "./languages";
|
||||
import { multiCaretMouse } from "./multiCaret";
|
||||
@@ -275,12 +276,14 @@ export function prefsFor(o: {
|
||||
info: LanguageInfo | undefined;
|
||||
readOnly: boolean;
|
||||
wrapOverride?: boolean | null;
|
||||
renderedDocsOverride?: boolean | null;
|
||||
}): Extension {
|
||||
return prefsExtension({
|
||||
prefs: prefsOf(o.settings),
|
||||
path: o.path,
|
||||
softWrap: o.settings?.softWrap,
|
||||
wrapOverride: o.wrapOverride,
|
||||
renderedDocsOverride: o.renderedDocsOverride,
|
||||
showWhitespace: o.settings?.showWhitespace,
|
||||
hardWrapAt: hardWrapAt(o.settings, o.info),
|
||||
readOnly: o.readOnly,
|
||||
@@ -314,6 +317,8 @@ export interface SetupOptions {
|
||||
formatIndent?: EffectiveIndent | null;
|
||||
/** Soft wraps toggled for this file (View › Active Editor › Soft-Wrap). */
|
||||
wrapOverride?: boolean | null;
|
||||
/** Rendered doc comments toggled for this file (View › Active Editor › Render Documentation Comments). */
|
||||
renderedDocsOverride?: boolean | null;
|
||||
/** Extra extensions owned by the slot (update listeners, focus handlers). */
|
||||
extra?: Extension;
|
||||
/** Standalone value editors supply their own extensions without file-backed contributions. */
|
||||
@@ -384,6 +389,7 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
overviewLane,
|
||||
cornerInset,
|
||||
codeLensExtension,
|
||||
renderedDocsExtension,
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: "false",
|
||||
autocorrect: "off",
|
||||
@@ -406,6 +412,7 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
info: o.info,
|
||||
readOnly: o.ctx.readOnly,
|
||||
wrapOverride: o.wrapOverride,
|
||||
renderedDocsOverride: o.renderedDocsOverride,
|
||||
}),
|
||||
),
|
||||
compartments.contributions.of(o.contributions ?? contributionExtensions(o.ctx)),
|
||||
|
||||
@@ -332,6 +332,8 @@ export const openideaTheme = EditorView.theme({
|
||||
|
||||
// Find in file (cm/find.ts)
|
||||
...findStyles,
|
||||
// Find in Files preview matches (search/find/parts.tsx)
|
||||
".cm-openidea-searchResult": { backgroundColor: "var(--bg-search-result)" },
|
||||
|
||||
// Overlays
|
||||
".cm-tooltip": {
|
||||
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача