Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai c5bccf020e fix(vcs): don't commit a file that was added to the index and then deleted
A file added with git add and then deleted from disk (AD) is no change
against HEAD, so the Commit view lists it nowhere. The commit plan skipped
it, but it stayed in the index copy the commit is made from, so committing
other files also committed the deleted file. Such staged entries are now
excluded like unchecked staged files.
2026-11-12 19:15:10 +08:00
140 змінених файлів з 325 додано та 3708 видалено

No files matched your search

Створено
-13
Переглянути файл
@@ -4443,7 +4443,6 @@ dependencies = [
"ide-tools",
"ide-vcs",
"ide-vfs",
"ide-web",
"ide-workspace",
"ignore",
"jsonschema",
@@ -4602,7 +4601,6 @@ dependencies = [
"tree-sitter-nix",
"tree-sitter-ocaml",
"tree-sitter-php",
"tree-sitter-prisma-io",
"tree-sitter-proto",
"tree-sitter-python",
"tree-sitter-ruby",
@@ -4728,7 +4726,6 @@ name = "ide-remote"
version = "0.1.43"
dependencies = [
"dunce",
"futures",
"globset",
"ide-core",
"ide-run",
@@ -10440,16 +10437,6 @@ 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"
+5 -21
Переглянути файл
@@ -1343,15 +1343,6 @@ 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();
@@ -1362,14 +1353,9 @@ impl AgentManager {
.await
}
/// 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<()> {
/// 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<()> {
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"));
@@ -1380,10 +1366,8 @@ impl AgentManager {
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
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 changes = at.changes(&before)?;
let rev = at.rev()?;
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
+1 -40
Переглянути файл
@@ -484,8 +484,7 @@ pub struct NewAgentTask {
pub base: Option<String>,
}
/// A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
/// grouped view.
/// A file the task changed, relative to [`AgentTask::base_commit`].
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentChange {
@@ -494,44 +493,6 @@ 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`).
+8 -29
Переглянути файл
@@ -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()]), AgentChangesView::SinceStart).await.unwrap();
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).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, AgentChangesView::SinceStart).await.unwrap();
mgr.rollback_changes(&task.id, None).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, AgentChangesView::SinceStart).await.unwrap();
mgr.rollback_changes(&task.id, None).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, AgentChangesView::SinceStart).await.unwrap();
mgr.rollback_changes(&task.id, None).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, AgentChangesView::SinceStart).await.unwrap();
mgr.rollback_changes(&task.id, None).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, AgentChangesView::SinceStart).await.unwrap();
mgr.rollback_changes(&task.id, None).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), AgentChangesView::SinceStart).await.unwrap();
mgr.rollback_changes(&task.id, Some(listed)).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,27 +3082,6 @@ 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 {
@@ -3113,7 +3092,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, AgentChangesView::SinceStart).await.unwrap();
mgr.rollback_changes(&task.id, None).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:?}");
+4 -180
Переглянути файл
@@ -1,8 +1,8 @@
//! Task worktrees and the changes made in them.
use crate::model::{AgentChange, AgentChanges, AgentCommit};
use crate::model::AgentChange;
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_vcs::{ChangeKind, LogQuery, Repo};
use ide_vcs::{ChangeKind, Repo};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
@@ -35,14 +35,6 @@ 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)?;
@@ -119,14 +111,7 @@ 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,
old_path: f.old_path,
}
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
})
.collect();
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
@@ -155,7 +140,6 @@ 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))
})
@@ -164,57 +148,7 @@ 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, 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)
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
}
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
@@ -309,34 +243,6 @@ 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.
@@ -692,88 +598,6 @@ 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();
+1 -1
Переглянути файл
@@ -204,7 +204,7 @@ pub fn parse_settings(text: &str) -> Settings {
}
/// `a/b/../c` → `a/c`, without touching the filesystem.
pub(crate) fn normalize(p: &Path) -> PathBuf {
fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
-8
Переглянути файл
@@ -122,14 +122,6 @@ 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,
+31 -337
Переглянути файл
@@ -106,9 +106,7 @@ 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` (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.
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
pub command: String,
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
pub options: Vec<String>,
@@ -1034,6 +1032,10 @@ 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`
@@ -1047,29 +1049,18 @@ pub fn is_maven_project(root: &Path) -> bool {
!top_level_poms(root).is_empty()
}
/// 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.
/// The `mvnw` nearest to `dir`, looking up to `root`.
fn wrapper(root: &Path, dir: &Path) -> String {
let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
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());
let mut cur = Some(dir);
while let Some(d) = cur {
if d.join(name).is_file() {
return d.join(name).to_string_lossy().into_owned();
}
if !external && d == root {
if d == root {
break;
}
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);
cur = d.parent();
}
if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() }
}
@@ -1113,118 +1104,24 @@ pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[St
load_with_linked(root, local_repo, profiles, &[])
}
/// 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.
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
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 Some(path) = dunce::canonicalize(base.join(pom.trim())).ok() else { continue };
if is_pom_file(&path) && !out.contains(&path) {
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) {
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()
@@ -1233,9 +1130,7 @@ 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, 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.
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
pub fn load_with_linked(
root: &Path,
local_repo: Option<PathBuf>,
@@ -1244,10 +1139,7 @@ pub fn load_with_linked(
) -> MavenModel {
let mut resolver = Resolver::new(local_repo, profiles);
let mut model = MavenModel::default();
// 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 seen: HashSet<PathBuf> = HashSet::new();
let mut tops = top_level_poms(root);
for pom in linked_poms(root, linked) {
if !tops.contains(&pom) {
@@ -1255,31 +1147,17 @@ pub fn load_with_linked(
}
}
// (pom, aggregated); the root reactor and its modules come before linked POMs.
// (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();
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
while let Some((path, aggregated)) = stack.pop() {
if model.projects.len() >= MAX_PROJECTS {
break;
}
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
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;
}
if !seen.insert(canonical) {
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);
@@ -1290,7 +1168,7 @@ pub fn load_with_linked(
}
let mut modules: Vec<PathBuf> = Vec::new();
for m in module_names {
let mut mp = crate::gradle::normalize(&dir.join(i(&m)));
let mut mp = dir.join(i(&m));
if mp.is_dir() || mp.extension().is_none_or(|e| e != "xml") {
mp.push("pom.xml");
}
@@ -1354,39 +1232,28 @@ 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: linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical)),
dir: project_rel(&canonical_root, &dir_canon),
pom: rel_path(root, &path),
dir: rel_path(root, &dir),
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: module_ids,
modules: modules.iter().map(|m| rel_path(root, m)).collect(),
aggregated,
plugins,
dependencies,
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
java_version: jv,
command: wrapper(&canonical_root, &dir_canon),
command: wrapper(root, &dir),
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, Some((origin, inside))));
stack.push((m, true));
}
}
model
@@ -1474,27 +1341,17 @@ impl UnlinkedProjects {
fn normalized_pom(pom: &str) -> String {
let pom = pom.replace('\\', "/");
let unc = pom.starts_with("//");
let absolute = pom.starts_with('/');
let mut parts: Vec<&str> = Vec::new();
let mut parts = Vec::new();
for part in pom.split('/') {
match part {
"" | "." => {}
// Keep a drive letter (`C:`) and a leading `..` that has nothing to climb.
".." if parts.last().is_some_and(|p| *p != ".." && !p.ends_with(':')) => {
".." if parts.last().is_some_and(|p| *p != "..") => {
parts.pop();
}
part => parts.push(part),
}
}
let joined = parts.join("/");
if unc {
format!("//{joined}")
} else if absolute {
format!("/{joined}")
} else {
joined
}
parts.join("/")
}
/// Every POM of the model (for change detection).
@@ -2290,169 +2147,6 @@ 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();
+1 -3
Переглянути файл
@@ -1182,9 +1182,7 @@ pub struct MavenSettings {
pub runner_jre: String,
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// 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.
/// Project-relative POMs outside the root reactor added as independent top-level projects.
pub linked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
+3 -38
Переглянути файл
@@ -283,16 +283,6 @@ 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`.
@@ -886,7 +876,9 @@ impl Pool {
if !source.database.is_empty() {
config.database(&source.database);
}
apply_sqlserver_ssl(&mut config, source.ssl);
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
config.trust_cert();
}
let manager = MssqlConnectionManager(config);
let pool = bb8::Pool::builder()
.max_size(MAX)
@@ -1339,33 +1331,6 @@ 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());
-1
Переглянути файл
@@ -46,7 +46,6 @@ 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
-4
Переглянути файл
@@ -84,9 +84,6 @@ 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).
@@ -143,7 +140,6 @@ 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"),
+7 -20
Переглянути файл
@@ -5,9 +5,9 @@ use crate::Host;
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpScope, McpServer, McpServerInput, NewAgentTask,
AcpImportOutcome, AgentAcceptOutcome, AgentChange, 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,28 +304,16 @@ 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 `view` compares them.
pub async fn rollback_changes(
host: &Host,
window: &str,
task: String,
paths: Option<Vec<String>>,
view: Option<AgentChangesView>,
) -> IdeResult<()> {
/// 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<()> {
host.require_trust(window)?;
host.project(window)?.agents.rollback_changes(&task, paths, view.unwrap_or_default()).await
host.project(window)?.agents.rollback_changes(&task, paths).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
@@ -488,10 +476,9 @@ 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")?, c.arg("view")?).await
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
});
d.add("agent_accept_changes", |c| async move {
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
-1
Переглянути файл
@@ -35,7 +35,6 @@ pub mod syntax;
pub mod term;
pub mod tools;
pub mod vcs;
pub mod web;
use crate::Host;
use ide_core::{IdeError, IdeResult};
+1 -12
Переглянути файл
@@ -164,21 +164,10 @@ 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(mut value) = host.take_proxy_password(key, value)? else {
let Some(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 &current {
Some(p) if project_scope => p.settings.set(scope, key, value)?,
Some(p) => {
+8 -48
Переглянути файл
@@ -3,7 +3,6 @@ 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).
@@ -139,24 +138,11 @@ 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, 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`).
/// The project `config` runs in (a workspace member, by its working directory) and its settings.
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);
@@ -194,8 +180,8 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
if c.kind == "gradle" {
return Ok(c);
}
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
Ok(ide_sdk::apply::without_derived_in(&c, &root, fallback.as_deref(), &settings))
let (root, settings) = settings_for(host, &project, &mut c)?;
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
})
.collect()
}
@@ -208,13 +194,8 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
.into_iter()
.map(|mut c| {
if c.kind != "gradle" {
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,
);
let (root, settings) = settings_for(host, &project, &mut c)?;
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
}
Ok(c)
})
@@ -348,7 +329,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, fallback) = settings_for(host, &project, &mut config)?;
let (root, settings) = 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,
@@ -357,38 +338,17 @@ 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_in(
std::slice::from_mut(&mut config),
root,
fallback.as_deref(),
&settings,
);
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
} else {
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
}
if config.kind == "go" {
ide_sdk::apply::apply_to_run_configs_in(
std::slice::from_mut(&mut config),
root,
fallback.as_deref(),
&settings,
);
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &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"));
+1 -8
Переглянути файл
@@ -69,14 +69,7 @@ pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) ->
}
None => (project.root.clone(), project.settings.effective()),
};
// 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))
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
-20
Переглянути файл
@@ -373,25 +373,6 @@ 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,
@@ -1411,7 +1392,6 @@ 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
});
-11
Переглянути файл
@@ -1,11 +0,0 @@
//! 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
}
+3 -35
Переглянути файл
@@ -142,10 +142,6 @@ 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();
@@ -1124,10 +1120,6 @@ 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();
@@ -1247,9 +1239,8 @@ pub(crate) fn workspace_member_roots(root: &Path) -> Vec<PathBuf> {
.unwrap_or_default()
}
/// 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).
/// Workspace members in manifest order, or extra Project Structure directories and the
/// Gradle project folders outside the project as of the last sync (`build_cache`).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
if workspace.is_container(root) {
@@ -1341,10 +1332,7 @@ 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))
.chain(ide_build::maven_external_roots(root, &settings.languages.java.maven.linked_projects))
{
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
@@ -1780,26 +1768,6 @@ 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();
-1
Переглянути файл
@@ -265,7 +265,6 @@ 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")?) })
-71
Переглянути файл
@@ -1,71 +0,0 @@
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;
}
-6
Переглянути файл
@@ -126,12 +126,6 @@ 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}");
-50
Переглянути файл
@@ -105,56 +105,6 @@ 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};
-1
Переглянути файл
@@ -55,7 +55,6 @@ 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]
-40
Переглянути файл
@@ -1,40 +0,0 @@
; 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
-8
Переглянути файл
@@ -1,8 +0,0 @@
(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
-2
Переглянути файл
@@ -67,7 +67,6 @@ 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")),
@@ -1027,7 +1026,6 @@ mod tests {
("CMakeLists.txt", "cmake"),
("api.proto", "proto"),
("schema.graphql", "graphql"),
("schema.prisma", "prisma"),
("main.tf", "terraform"),
("prod.tfvars", "terraform-vars"),
("job.nomad", "hcl"),
-10
Переглянути файл
@@ -430,16 +430,6 @@ 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,
-22
Переглянути файл
@@ -501,28 +501,6 @@ 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",
-11
Переглянути файл
@@ -886,17 +886,6 @@ 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",
-3
Переглянути файл
@@ -430,9 +430,6 @@ impl Handler for ClientHandler {
crate::kotlin_lsp::record_import_state(&self.server, self.generation, &params)
}
"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" => {
+8 -58
Переглянути файл
@@ -201,10 +201,6 @@ 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>,
@@ -448,7 +444,6 @@ 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();
@@ -508,28 +503,6 @@ 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));
@@ -564,29 +537,10 @@ 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()
@@ -603,9 +557,7 @@ 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) || 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.
let root = if shared.as_ref().is_some_and(|state| state.shared) {
self.project_root.clone()
} else {
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
@@ -1001,15 +953,13 @@ impl LspManager {
}
init_options["settings"] = handler::all_settings(&cfg);
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
// 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);
if shared {
init_options["workspaceFolders"] = json!(
self.server_workspace_roots(server)
.iter()
.map(|root| uri::path_to_uri_string(root))
.collect::<Vec<_>>()
);
}
let triggers: Vec<_> = self
.docs
+4 -4
Переглянути файл
@@ -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.indexing()).then_some(ReferencesIncomplete::Indexing);
let incomplete = (empty && server.is_busy()).then_some(ReferencesIncomplete::Indexing);
Self { first, second: None, incomplete }
}
}
@@ -130,7 +130,7 @@ impl LspManager {
F: Fn() -> Fut,
Fut: Future<Output = IdeResult<R>>,
{
let indexing = t.server.indexing();
let busy = t.server.is_busy();
// 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 indexing && covered(&first).is_empty() && t.server.wait_indexed(INDEX_WAIT, cancel).await {
if busy && covered(&first).is_empty() && t.server.wait_idle(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.indexing() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
(t.server.is_busy() && 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));
+8 -28
Переглянути файл
@@ -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 an empty answer stops being suspect (see [`Server::indexing`]).
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
/// one, then whether it is still loading the project.
@@ -148,8 +148,6 @@ 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>>,
}
@@ -197,7 +195,6 @@ 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,
}
}
@@ -599,22 +596,6 @@ 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)
@@ -632,7 +613,7 @@ impl Server {
fn phase_ended(&self) {
self.phase_notify.notify_waiters();
if !self.indexing() {
if !self.is_busy() {
self.idle_notify.notify_waiters();
}
}
@@ -642,17 +623,17 @@ impl Server {
self.phase_notify.notified()
}
/// Waits until [`Server::indexing`] ends; false on timeout or cancellation.
pub async fn wait_indexed(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.idle_notify.notified();
if !self.indexing() || self.running().is_none() {
return !self.indexing();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.indexing(),
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = cancel.cancelled() => return false,
}
}
@@ -742,7 +723,6 @@ 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()
}
@@ -977,7 +957,7 @@ mod tests {
stalled: true,
},
);
assert!(!s.is_busy(), "a stuck task must not hold wait_indexed or ReferencesIncomplete::Indexing");
assert!(!s.is_busy(), "a stuck task must not hold wait_idle or ReferencesIncomplete::Indexing");
s.progress.lock().get_mut("p").unwrap().stalled = false;
assert!(s.is_busy(), "a reporting task keeps the server busy");
}
-67
Переглянути файл
@@ -759,40 +759,6 @@ 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())], &[]);
@@ -4959,39 +4925,6 @@ 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")]
-1
Переглянути файл
@@ -14,7 +14,6 @@ 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
+8 -26
Переглянути файл
@@ -10,9 +10,6 @@ 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)]
@@ -202,35 +199,20 @@ 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<()> {
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 };
for e in self.fs.list(dir).await? {
if self.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
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);
}
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);
}
}
out[start..].sort_by(|a, b| a.path.cmp(&b.path));
Ok(())
}
+12 -32
Переглянути файл
@@ -470,27 +470,13 @@ 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;
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),
};
let synced = sync_external_libraries(&fs, &libraries, preserve_time).await;
fs.close().await;
let report = report?;
out(OutputChunk::System {
text: format!(
"Uploaded {} changed file(s) to {remote_root} in {:.1}s\n",
report.files,
started.elapsed().as_secs_f64()
),
text: format!("Uploaded {} changed file(s) to {remote_root}\n", report.files),
});
match report.errors.first() {
Some(e) => Err(IdeError::new(
@@ -531,8 +517,9 @@ 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 files = Vec::new();
let mut synced = 0;
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() {
@@ -547,11 +534,7 @@ async fn sync_external_libraries(
} else {
files.push((local.clone(), remote_base.clone()));
}
}
// 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 {
for (file, remote) in files {
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))? {
@@ -561,17 +544,14 @@ async fn sync_external_libraries(
Some(e) => Some(FileStamp { size: e.size, mtime: e.mtime }),
None => None,
};
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))?;
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;
}
}
Ok(stale.len())
Ok(synced)
}
fn sanitize(s: &str) -> String {
+2 -14
Переглянути файл
@@ -263,15 +263,10 @@ 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,
@@ -323,7 +318,6 @@ 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(),
});
@@ -367,8 +361,7 @@ impl RemoteRuns {
Ok(())
}
/// `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.
/// `INT`, then `KILL` (closing the channel) after `grace`.
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}")))?;
@@ -381,8 +374,6 @@ 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(())
@@ -429,10 +420,7 @@ async fn run_remote(
.await?;
let process = Arc::new(process);
*run.process.lock() = Some(process.clone());
tokio::select! {
code = process.wait() => Ok(code),
_ = run.abandon.cancelled() => Ok(None),
}
Ok(process.wait().await)
}
#[cfg(test)]
+7 -36
Переглянути файл
@@ -66,19 +66,16 @@ 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. 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.
/// root, else the Python the project pins for mise / asdf.
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).filter(|p| p.is_file()) {
if let Some(python) = setting_path(root, &s.languages.python.interpreter) {
return Some(python);
}
let near = near.filter(|dir| dir.starts_with(root)).unwrap_or(root);
@@ -1449,22 +1446,7 @@ 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) {
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 explicit_python = setting_path(root, &s.languages.python.interpreter);
let pm = non_empty(&s.languages.node.package_manager);
let php = setting_path(root, &s.languages.php.interpreter);
let rust = rust_bin(root, s);
@@ -1482,9 +1464,7 @@ pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback:
// 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 {
// 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))),
"python" => python_interpreter_near(root, Some(near), s),
"node" if non_empty(&s.languages.node.interpreter).is_none() => {
node_dir(near, s).or_else(|| node_dir(root, s))
}
@@ -1516,10 +1496,7 @@ pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback:
let mut path_dirs: Vec<PathBuf> = Vec::new();
match c.kind.as_str() {
"python" | "pytest" | "django-test" => {
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()) {
if let Some(py) = &explicit_python {
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") {
@@ -1538,6 +1515,7 @@ pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback:
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")
{
@@ -1675,16 +1653,9 @@ pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback:
/// `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_in(std::slice::from_mut(&mut c), root, fallback, s);
apply_to_run_configs(std::slice::from_mut(&mut c), root, s);
c
};
let mut out = config.clone();
+15 -121
Переглянути файл
@@ -1,7 +1,6 @@
use crate::apply::{
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,
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,
};
use crate::python::parse_pip_list;
use crate::{bin_dir, exe, venv_of};
@@ -63,14 +62,6 @@ 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) {
@@ -83,10 +74,8 @@ 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();
// 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);
s.languages.python.interpreter = "/opt/py/bin/python3".into();
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
}
#[test]
@@ -315,9 +304,7 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
}
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
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.python.interpreter = "/opt/py/bin/python3".into();
s.languages.node.package_manager = "pnpm".into();
let mut configs = vec![
run("python", "uv", &["run", "python", "-m", "app"]),
@@ -329,17 +316,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, python);
assert_eq!(configs[0].command, "/opt/py/bin/python3");
assert_eq!(configs[0].args, ["-m", "app"]);
assert_eq!(configs[1].command, python);
assert_eq!(configs[1].command, "/opt/py/bin/python3");
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, python);
assert_eq!(configs[5].command, "/opt/py/bin/python3");
assert_eq!(configs[5].args, ["manage.py", "test"]);
assert_eq!(configs[6].command, python);
assert_eq!(configs[6].command, "/opt/py/bin/python3");
assert_eq!(configs[6].args, ["manage.py", "test", "polls"]);
}
@@ -2244,13 +2231,11 @@ 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 = 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();
settings.languages.python.interpreter = "custom/bin/python".into();
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").join(exe("python")).to_string_lossy());
assert_eq!(config.command, root.join("custom/bin/python").to_string_lossy());
}
#[test]
@@ -2263,94 +2248,8 @@ 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));
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());
settings.languages.python.interpreter = "selected/python".into();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python")));
}
#[test]
@@ -2451,17 +2350,12 @@ 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);
}
+1 -11
Переглянути файл
@@ -230,14 +230,7 @@ impl Session {
let mut on_output = on_output;
tokio::spawn(async move {
let mut code = None;
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 };
while let Some(msg) = read.wait().await {
match msg {
ChannelMsg::Data { data } => on_output(Output::Stdout(&data)),
ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)),
@@ -324,9 +317,6 @@ 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]),
+3 -30
Переглянути файл
@@ -59,8 +59,7 @@ impl server::Handler for Sshd {
Ok(())
}
/// `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.
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3.
async fn exec_request(
&mut self,
channel: ChannelId,
@@ -71,10 +70,8 @@ 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)?;
if data != b"linger" {
session.eof(channel)?;
session.close(channel)?;
}
session.eof(channel)?;
session.close(channel)?;
Ok(())
}
@@ -297,30 +294,6 @@ 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;
-11
Переглянути файл
@@ -407,17 +407,6 @@ 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)",
-11
Переглянути файл
@@ -783,17 +783,6 @@ 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));
-50
Переглянути файл
@@ -255,56 +255,6 @@ 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>>> {
-36
Переглянути файл
@@ -125,8 +125,6 @@ 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,
@@ -404,40 +402,6 @@ 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)]
+3 -9
Переглянути файл
@@ -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()) {
} else if !host_allowed(req.host.as_deref(), root.port) {
Response::Error(403, "Forbidden")
} else {
resolve(root, &req.target).await
@@ -274,11 +274,8 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
sock.shutdown().await
}
/// 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())
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}"))
}
async fn resolve(root: &Root, target: &str) -> Response {
@@ -427,9 +424,6 @@ 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)]
+1 -1
Переглянути файл
@@ -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`, `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)).
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)).
### `[[framework-ui-modes]]`
-1
Переглянути файл
@@ -151,7 +151,6 @@ 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** |
+1 -2
Переглянути файл
@@ -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-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `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,7 +504,6 @@ 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` |
+1 -1
Переглянути файл
@@ -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`、`prisma`、`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`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
### `[[framework-ui-modes]]`
-1
Переглянути файл
@@ -146,7 +146,6 @@ 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** |
+1 -2
Переглянути файл
@@ -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-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `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,7 +469,6 @@ 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` |
-47
Переглянути файл
@@ -4,7 +4,6 @@
* 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";
@@ -79,52 +78,6 @@ 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(
-29
Переглянути файл
@@ -256,32 +256,3 @@ 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);
}
});
-14
Переглянути файл
@@ -105,20 +105,6 @@ 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");
-2
Переглянути файл
@@ -41,5 +41,3 @@ 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 = [] }
-2
Переглянути файл
@@ -75,8 +75,6 @@ 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"
-2
Переглянути файл
@@ -74,8 +74,6 @@ 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"
-17
Переглянути файл
@@ -1,17 +0,0 @@
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
-2
Переглянути файл
@@ -43,5 +43,3 @@ 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 = [] }
-2
Переглянути файл
@@ -89,8 +89,6 @@ 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"
-2
Переглянути файл
@@ -74,8 +74,6 @@ 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"
-2
Переглянути файл
@@ -68,5 +68,3 @@ 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 = [] }
-1
Переглянути файл
@@ -89,7 +89,6 @@
"--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",
+2 -3
Переглянути файл
@@ -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-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `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 -->
264 themeable tokens: 125 in the UI theme file, 139 in the editor colour scheme file.
263 themeable tokens: 125 in the UI theme file, 138 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -509,7 +509,6 @@ 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` |
-10
Переглянути файл
@@ -219,16 +219,6 @@ 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");
+4 -17
Переглянути файл
@@ -3,9 +3,8 @@
use crate::state::AppState;
use ide_agent::registry::AgentRegistry;
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpServer, McpServerInput, NewAgentTask,
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath, RequestId};
use ide_host::ops::agent;
@@ -371,18 +370,7 @@ pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: Str
agent::changes(&state.host, window.label(), task).await
}
/// 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.
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
#[tauri::command]
#[specta::specta]
pub async fn agent_rollback_changes(
@@ -390,9 +378,8 @@ 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, view).await
agent::rollback_changes(&state.host, window.label(), task, paths).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
-2
Переглянути файл
@@ -154,7 +154,6 @@ 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,
@@ -304,7 +303,6 @@ 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,
+4 -2
Переглянути файл
@@ -111,8 +111,10 @@ 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)))));
// 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()));
// 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()));
}
#[cfg(target_os = "macos")]
host.set_secret_store(Arc::new(KeychainSecrets));
ide_host::plugins::set_editor_providers(Some(&host));
-13
Переглянути файл
@@ -51,19 +51,6 @@ 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]
+2 -1
Переглянути файл
@@ -17,7 +17,8 @@ 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> {
ide_host::ops::web::url(&state.host, window.label(), &path).await
let root = state.project(window.label()).ok().map(|p| p.root.clone());
state.web.url_for(root.as_deref(), &path).await
}
/// Opens `url` in `browser` (an id from `web_browsers`), or the default browser.
+1 -36
Переглянути файл
@@ -76,8 +76,6 @@ 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.
@@ -546,7 +544,6 @@ 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 {
@@ -752,34 +749,6 @@ 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);
@@ -889,12 +858,8 @@ pub async fn remote_invoke(
} else {
None
};
let web_url = cmd == "web_url";
let call = Invoke { window: label, cmd, args };
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?);
}
let result = invoke_reply(&c.peer, call, std::time::Duration::from_secs(30)).await?;
if let (Some((peers, edit)), Some(version)) =
(mirrored, result.as_u64().and_then(|version| u32::try_from(version).ok()))
{
+3
Переглянути файл
@@ -19,6 +19,8 @@ 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).
@@ -106,6 +108,7 @@ 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,
+5 -59
Переглянути файл
@@ -214,16 +214,10 @@ 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 `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 })),
/** 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 })),
/**
* 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.
@@ -616,11 +610,6 @@ 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
@@ -1743,38 +1732,15 @@ export type AgentCapabilities = {
backgroundTasks?: boolean,
};
/**
* A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
* grouped view.
*/
/** A file the task changed, relative to [`AgentTask::base_commit`]. */
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,
@@ -1789,18 +1755,6 @@ 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,
@@ -6895,11 +6849,7 @@ 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` (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.
*/
/** Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`. */
command: string,
/** Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings. */
options: string[],
@@ -6913,11 +6863,7 @@ export type MavenSettings = {
runnerJre?: string,
/** Project-relative root POMs removed from the Maven project model. */
unlinkedProjects?: string[],
/**
* 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.
*/
/** Project-relative POMs outside the root reactor added as independent top-level projects. */
linkedProjects?: string[],
/** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */
home?: string,
-2
Переглянути файл
@@ -332,8 +332,6 @@ 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": {
+2 -3
Переглянути файл
@@ -271,9 +271,8 @@
.tabsVertical > .tabs,
.tabsVertical > .tabRows {
width: max-content;
/* A narrow editor group keeps most of its width for the editor; labels truncate. */
min-width: min(140px, 40%);
max-width: min(260px, 40%);
min-width: 140px;
max-width: 260px;
border-bottom: none;
border-right: 1px solid var(--border-editor-tabs, var(--border));
}
-38
Переглянути файл
@@ -318,44 +318,6 @@ describe("applying completions", () => {
});
for (const mode of ["insert", "replace"] as const) {
for (const prefix of ["", "v"]) {
it(`${mode} leaves the caret after a field snippet accepted at root.${prefix}`, () => {
const { view, req, pos } = setup(`int a = root.${prefix}|Suffix`);
const start = "int a = root.".length;
const c = makeCompletion(
item({
label: "val",
kind: "field",
insertText: "val",
isSnippet: true,
replaceRange: { start: { line: 0, col: start }, end: { line: 0, col: pos } },
}),
req,
);
applyLspCompletion(view, c, start, pos, mode);
expect(view.state.doc.toString()).toBe(`int a = root.val${mode === "insert" ? "Suffix" : ""}`);
expect(view.state.selection.main.head).toBe(start + 3);
});
}
it(`${mode} places a fieldless snippet after imports and a cached UTF-16 prefix`, () => {
const { view, req, pos } = setup("\n/* 😀 */ root.|Suffix", "v");
const c = makeCompletion(
item({
label: "val",
kind: "field",
insertText: "val",
isSnippet: true,
replaceRange: { start: { line: 1, col: pos - 1 }, end: { line: 1, col: pos - 1 } },
additionalEdits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "import X;" }],
}),
req,
);
applyLspCompletion(view, c, pos, pos + 1, mode);
expect(view.state.doc.toString()).toBe(`import X;\n/* 😀 */ root.val${mode === "insert" ? "Suffix" : ""}`);
expect(view.state.selection.main.head).toBe(pos + "import X;val".length);
});
for (const shape of ["textEdit", "insertReplace", "noEdit"] as const) {
for (const isSnippet of [false, true]) {
it(`${mode} replaces a mixed-case prefix typed after a ${shape} request (snippet: ${isSnippet})`, () => {
-57
Переглянути файл
@@ -146,63 +146,6 @@ describe("silent code actions", () => {
});
});
describe("fix-all from every server", () => {
const fixAll = (server: number, title: string): CodeAction => ({
...resolved,
title,
kind: "source.fixAll",
server,
edit: null,
data: "{}",
});
const editor = () => {
const view = new EditorView({
state: EditorState.create({
doc: "const a = 'x';",
extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })],
}),
});
views.push(view);
return view;
};
it("applies each server's source.fixAll in turn, asking the next one again after an edit", async () => {
const view = editor();
const ts = fixAll(1, "Fix all fixable JS/TS issues");
const eslint = fixAll(2, "Fix all fixable ESLint issues");
const eslintAfter = { ...eslint, data: '{"version":2}' };
lspCodeActions
.mockResolvedValueOnce({ status: "ok", data: [ts, eslint] })
.mockResolvedValueOnce({ status: "ok", data: [{ ...ts, data: '{"again":true}' }, eslintAfter] });
lspApplyCodeAction.mockResolvedValue({ status: "ok", data: null });
expect(await applyKindSilently(view, "source.fixAll")).toBe(true);
expect(lspCodeActions).toHaveBeenCalledTimes(2);
expect(lspApplyCodeAction.mock.calls.map((c) => (c as unknown[])[1])).toEqual([ts, eslintAfter]);
});
it("goes on with the next server when one server's action fails", async () => {
const view = editor();
const ts = fixAll(1, "Fix all fixable JS/TS issues");
const eslint = fixAll(2, "Fix all fixable ESLint issues");
lspCodeActions.mockResolvedValue({ status: "ok", data: [ts, eslint] });
lspApplyCodeAction
.mockResolvedValueOnce({ status: "error", error: { kind: "internal", message: "boom" } })
.mockResolvedValueOnce({ status: "ok", data: null });
expect(await applyKindSilently(view, "source.fixAll")).toBe(true);
expect(lspApplyCodeAction.mock.calls.map((c) => ((c as unknown[])[1] as CodeAction).server)).toEqual([1, 2]);
});
it("applies a single organize-imports action", async () => {
const view = editor();
const a = { ...fixAll(1, "Organize Imports"), kind: "source.organizeImports" };
const b = { ...fixAll(2, "Organize Imports"), kind: "source.organizeImports" };
lspCodeActions.mockResolvedValueOnce({ status: "ok", data: [a, b] });
lspApplyCodeAction.mockResolvedValue({ status: "ok", data: null });
expect(await applyKindSilently(view, "source.organizeImports")).toBe(true);
expect(lspApplyCodeAction).toHaveBeenCalledTimes(1);
});
});
describe("intention preview", () => {
it("does not resolve lazy plugin actions while previewing", async () => {
const view = new EditorView({
+13 -47
Переглянути файл
@@ -299,61 +299,27 @@ export function changedLines(before: Text, after: Text): Array<{ from: number; t
* Applies the first enabled whole-file action of `kind` without UI (Actions on
* Save). Waits until the backend edit reached this editor; false when none.
*/
/**
* Applies the server's `kind` action to the whole document without UI (Actions on Save,
* Code Cleanup). Fix-all runs once per server that offers it, one after another, each
* asked again for the document as the previous one left it (VS Code's codeActionsOnSave):
* vtsls and ESLint both answer `source.fixAll` for a TypeScript file.
*/
export async function applyKindSilently(view: EditorView, kind: string): Promise<boolean> {
const doc = docOf(view);
if (!doc || doc.readOnly) return false;
const matching = (actions: CodeAction[], server?: CodeAction["server"]) =>
actions.filter(
(a) =>
a.kind?.startsWith(kind) &&
!a.disabledReason &&
!isLazyPluginAction(a) &&
(server === undefined || a.server === server),
);
const pick = (actions: CodeAction[]) => actions.find((a) => a.isPreferred) ?? actions[0];
let actions: CodeAction[];
try {
actions = matching(await fetchActions(view, [kind], true));
} catch (e) {
console.warn(`[lsp] ${kind} on ${doc.path} failed`, e);
actions = (await fetchActions(view, [kind], true)).filter(
(a) => a.kind?.startsWith(kind) && !a.disabledReason && !isLazyPluginAction(a),
);
} catch {
return false;
}
const first = pick(actions);
if (!first) return false;
const servers = kind.startsWith("source.fixAll")
? [first.server, ...new Set(actions.map((a) => a.server).filter((s) => s !== first.server))]
: [first.server];
let applied = false;
for (const [i, server] of servers.entries()) {
let action: CodeAction | undefined = first;
if (i > 0) {
// The previous server's edit changed the document: this one's answer must be fresh.
try {
action = pick(matching(await fetchActions(view, [kind], true), server));
} catch (e) {
console.warn(`[lsp] ${kind} on ${doc.path} failed`, e);
continue;
}
if (!action) continue;
}
const before = view.state.doc;
const chosen = action;
try {
await lspRequest(() => commands.lspApplyCodeAction(doc.doc, chosen));
} catch (e) {
console.warn(`[lsp] applying ${chosen.kind} on ${doc.path} failed`, e);
continue;
}
applied = true;
for (let i = 0; i < 25 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20));
const action = actions.find((a) => a.isPreferred) ?? actions[0];
if (!action) return false;
const before = view.state.doc;
try {
await lspRequest(() => commands.lspApplyCodeAction(doc.doc, action));
} catch {
return false;
}
return applied;
for (let i = 0; i < 25 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20));
return true;
}
/**
+4 -29
Переглянути файл
@@ -23,7 +23,7 @@ describe("lspSnippetToCm", () => {
});
it("handles LSP escapes", () => {
expect(lspSnippetToCm("cost \\$5 \\} \\\\")).toBe("cost $5 \\} \\" + "${0}");
expect(lspSnippetToCm("cost \\$5 \\} \\\\")).toBe("cost $5 \\} \\");
expect(expand(lspSnippetToCm("a \\${1}")).text).toBe("a ${1}");
});
@@ -37,8 +37,8 @@ describe("lspSnippetToCm", () => {
it("resolves variables, defaults and unknown names", () => {
const vars = snippetVariables("/p/src/Main.kt");
expect(lspSnippetToCm("class $TM_FILENAME_BASE", vars)).toBe("class Main${0}");
expect(lspSnippetToCm("${NOPE:dflt}", vars)).toBe("dflt${0}");
expect(lspSnippetToCm("class $TM_FILENAME_BASE", vars)).toBe("class Main");
expect(lspSnippetToCm("${NOPE:dflt}", vars)).toBe("dflt");
expect(lspSnippetToCm("$NOPE", vars)).toBe("${NOPE}");
});
@@ -51,7 +51,7 @@ describe("lspSnippetToCm", () => {
});
it("treats a lone dollar as text", () => {
expect(lspSnippetToCm("$ x $")).toBe("$ x $${0}");
expect(lspSnippetToCm("$ x $")).toBe("$ x $");
});
it("selects the first field when instantiated", () => {
@@ -60,31 +60,6 @@ describe("lspSnippetToCm", () => {
expect([sel.from, sel.to]).toEqual([5, 8]);
});
it.each([
["val", {}, "val"],
["$TM_FILENAME_BASE", { TM_FILENAME_BASE: "Main" }, "Main"],
["${MISSING:val}", {}, "val"],
["\\${1}", {}, "${1}"],
["cost \\$5", {}, "cost $5"],
["first\n\tlast", {}, "first\n last"],
["/* 😀 */ val", {}, "/* 😀 */ val"],
])("places the caret at the end of a fieldless snippet (%s)", (source, vars, expected) => {
const { text, sel } = expand(lspSnippetToCm(source, vars));
expect(text).toBe(expected);
expect([sel.from, sel.to]).toEqual([expected.length, expected.length]);
});
it.each([
["before$0after", "beforeafter", 6, 6],
["${1:arg}after", "argafter", 0, 3],
["${1|val,other|}after", "valafter", 0, 3],
["$UNKNOWN after", "UNKNOWN after", 0, 7],
])("preserves the selected field in %s", (source, expected, from, to) => {
const { text, sel } = expand(lspSnippetToCm(source));
expect(text).toBe(expected);
expect([sel.from, sel.to]).toEqual([from, to]);
});
it("produces plain text previews", () => {
expect(lspSnippetToPlain("foo(${1:a}, ${2|x,y|})$0")).toBe("foo(a, x)");
});
+2 -7
Переглянути файл
@@ -189,12 +189,8 @@ function field(n: number | string, text: string): string {
/** Converts an LSP snippet into a CodeMirror snippet template. */
export function lspSnippetToCm(snippet: string, vars: SnippetVariables = {}): string {
const nodes = parse(snippet);
const hasField = nodes.some(
(n) => n.t === "tab" || n.t === "choice" || (n.t === "var" && vars[n.name] === undefined && !n.children),
);
let out = "";
for (const n of nodes) {
for (const n of parse(snippet)) {
if (n.t === "text") out += escapeText(n.v);
else if (n.t === "tab") out += field(n.n, n.children ? flatten(n.children, vars) : "");
else if (n.t === "choice") out += field(n.n, n.options[0] ?? "");
@@ -206,8 +202,7 @@ export function lspSnippetToCm(snippet: string, vars: SnippetVariables = {}): st
else out += field(n.name, "");
}
}
// CodeMirror leaves the caret before an insertion without fields.
return hasField ? out : out + "${0}";
return out;
}
/** The snippet's text with every field replaced by its default (for previews / plain insertion). */
-7
Переглянути файл
@@ -62,7 +62,6 @@ import { useCompletionAnchor } from "./useCompletionAnchor";
import { useAgentSettings } from "./agentSettings";
import styles from "./Composer.module.css";
import { isImeKey } from "@/platform/ime";
import { isMacHost } from "@/platform/keymap";
const MAX_PROMPT_HEIGHT = 280;
const MAX_ITEMS = 12;
@@ -455,12 +454,6 @@ export function Composer(props: ComposerProps) {
recalled.current = { index: -1, stash: "" };
return;
}
// macOS text boxes scroll on Home / End and leave the caret where it is.
if ((e.key === "Home" || e.key === "End") && !e.altKey && !e.ctrlKey && !e.metaKey && isMacHost()) {
e.preventDefault();
getSelection()?.modify(e.shiftKey ? "extend" : "move", e.key === "Home" ? "backward" : "forward", "lineboundary");
return;
}
if (e.key === "Tab" && e.shiftKey) {
const mode = modeOption(props.config);
const value = mode && nextValue(mode);
-41
Переглянути файл
@@ -1102,47 +1102,6 @@
font-variant-numeric: tabular-nums;
}
/* Group and commit rows of the grouped Changes pane. */
.changeGroup {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.changeGroupName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: var(--fg-strong);
}
.changeCommit {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.changeCommitHash {
flex: none;
color: var(--fg-subtle);
font-family: var(--font-mono);
}
.changeCommitSummary {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.changeCommitTime {
flex: none;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.added {
color: var(--vcs-added);
}
+43 -305
Переглянути файл
@@ -20,7 +20,6 @@ import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import type {
AgentChange,
AgentCommit,
AgentEntry,
AgentImage,
AgentLimit,
@@ -58,9 +57,7 @@ import {
} from "@/ui";
import { useTx, tx } from "@/platform/i18n";
import { isImeKey } from "@/platform/ime";
import { commitFileDiffData, openDiff, withChain, type DiffData } from "@/features/vcs/diffInput";
import { repositoryPath } from "@/features/vcs/repositoryPaths";
import { setChangesOptions, useChangesOptions } from "./changesOptions";
import { openDiff, withChain, type DiffData } from "@/features/vcs/diffInput";
import { Composer } from "./Composer";
import { selectionIn } from "./selection";
import { SubmitButton } from "./SubmitButton";
@@ -115,7 +112,6 @@ import {
markVisible,
refreshChanges,
rollbackChanges,
scheduleChanges,
editQueued,
forkTask,
rerunFrom,
@@ -140,13 +136,7 @@ export function SessionEditor({ input, docked = false }: { input: EditorInput; d
function TaskSession({ id, docked }: { id: string; docked: boolean }) {
const task = useAgents((s) => s.tasks.get(id));
const entries = useAgents((s) => s.entries.get(id));
// Grouping needs commits of the task's own: local checkouts keep the flat list.
const isolation = useAgents((s) => s.tasks.get(id)?.isolation);
const grouped = useChangesOptions((s) => s.groupByCommit) && isolation === "worktree";
const sinceStart = useAgents((s) => s.changes.get(id));
const review = useAgents((s) => s.groupedChanges.get(id));
const changes = grouped ? (review?.uncommitted ?? null) : (sinceStart ?? null);
const commits = grouped ? (review?.commits ?? []) : [];
const changes = useAgents((s) => s.changes.get(id));
const project = useProject((s) => s.project);
const head = useLocalHead(
task?.isolation === "local" ? task.status : null,
@@ -171,11 +161,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined);
void refreshChanges(id);
}, [id, connected]);
// A commit in the task's worktree moves its HEAD: pick it up without waiting for the agent.
useEffect(() => {
if (!connected) return;
return onIdeEvent("vcsChanged", () => scheduleChanges(id));
}, [id, connected]);
// A kept-alive tab stays mounted behind the shown one: it isn't read there, nor searched.
const shown = useEditorShown();
useEffect(() => {
@@ -239,7 +224,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
entries={entries ?? null}
head={head}
changes={changes ?? null}
commits={commits}
changesOpen={changesOpen}
onToggleChanges={() => setChangesOpen(!changesOpen)}
onOpenSubagent={openSubagent}
@@ -251,8 +235,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
<ChangesPane
task={task}
changes={changes ?? null}
commits={commits}
grouped={grouped}
head={head}
docked={docked}
onHide={() => setChangesOpen(false)}
@@ -2428,7 +2410,6 @@ function Tray({
task,
entries,
changes,
commits,
changesOpen,
onToggleChanges,
children,
@@ -2436,7 +2417,6 @@ function Tray({
task: AgentTask;
entries: AgentEntry[] | null;
changes: AgentChange[] | null;
commits: readonly AgentCommit[];
changesOpen: boolean;
onToggleChanges: () => void;
children: ReactNode;
@@ -2454,7 +2434,7 @@ function Tray({
return (
<div className={styles.tray}>
{pending && <PendingPermission task={task} entry={pending} />}
{(planShown || !!changes?.length || !!commits.length) && (
{(planShown || !!changes?.length) && (
<div className={styles.trayStrip}>
{planShown && (
<button
@@ -2478,12 +2458,6 @@ function Tray({
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
</button>
)}
{!!commits.length && (
<button type="button" className={styles.trayChip} aria-pressed={changesOpen} onClick={onToggleChanges}>
<Icon name="commit" size={13} />
{tx(commits.length === 1 ? "1 commit" : "{count} commits").replace("{count}", String(commits.length))}
</button>
)}
</div>
)}
{planShown && planOpen && (
@@ -2597,7 +2571,6 @@ function SessionComposer({
entries,
head,
changes,
commits,
changesOpen,
onToggleChanges,
onOpenSubagent,
@@ -2606,7 +2579,6 @@ function SessionComposer({
entries: AgentEntry[] | null;
head: string | null;
changes: AgentChange[] | null;
commits: readonly AgentCommit[];
changesOpen: boolean;
onToggleChanges: () => void;
onOpenSubagent: (id: string) => void;
@@ -2665,14 +2637,7 @@ function SessionComposer({
/>
</div>
)}
<Tray
task={task}
entries={entries}
changes={changes}
commits={commits}
changesOpen={changesOpen}
onToggleChanges={onToggleChanges}
>
<Tray task={task} entries={entries} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges}>
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
<BackgroundTasks task={task} />
{task.queue.length > 0 && <Queue task={task} />}
@@ -2831,115 +2796,25 @@ function useChangesOpen(
return [chosen ?? (changes?.length ?? 0) > 0, set];
}
const NO_SELECTION: ReadonlySet<string> = new Set();
/** A row of the Changes pane: a group, a commit, or a file. */
type ChangeRow =
| {
key: string;
type: "group";
label: "Uncommitted" | "Committed";
count: number;
added: number;
removed: number;
children: ChangeRow[];
}
| { key: string; type: "commit"; commit: AgentCommit; children: ChangeRow[] }
| { key: string; type: "file"; change: AgentChange; commit?: AgentCommit };
function statsOf(changes: readonly AgentChange[]) {
return changes.reduce((n, c) => ({ added: n.added + c.added, removed: n.removed + c.removed }), {
added: 0,
removed: 0,
});
}
function changeRows(
uncommitted: readonly AgentChange[],
commits: readonly AgentCommit[],
grouped: boolean,
): ChangeRow[] {
if (!grouped) return uncommitted.map((change) => ({ key: change.path, type: "file" as const, change }));
const roots: ChangeRow[] = [];
if (uncommitted.length) {
roots.push({
key: "group:uncommitted",
type: "group",
label: "Uncommitted",
count: uncommitted.length,
...statsOf(uncommitted),
children: uncommitted.map((change) => ({ key: `uncommitted:${change.path}`, type: "file", change })),
});
}
if (commits.length) {
const files = commits.flatMap((commit) => commit.files);
roots.push({
key: "group:committed",
type: "group",
label: "Committed",
count: commits.length,
...statsOf(files),
children: commits.map((commit) => ({
key: `commit:${commit.id}`,
type: "commit",
commit,
children: commit.files.map((change) => ({
key: `commit:${commit.id}:${change.path}`,
type: "file",
change,
commit,
})),
})),
});
}
return roots;
}
function ChangesPane({
task,
changes,
commits,
grouped,
head,
docked,
onHide,
}: {
task: AgentTask;
changes: AgentChange[] | null;
commits: readonly AgentCommit[];
grouped: boolean;
head: string | null;
docked: boolean;
onHide: () => void;
}) {
// The two views key their rows differently: a selection doesn't carry over.
const [selection, setSelection] = useState<{ grouped: boolean; keys: ReadonlySet<string> }>(() => ({
grouped,
keys: NO_SELECTION,
}));
const selected = selection.grouped === grouped ? selection.keys : NO_SELECTION;
const setSelected = (keys: ReadonlySet<string>) => setSelection({ grouped, keys });
const rows = useMemo(() => changeRows(changes ?? [], commits, grouped), [changes, commits, grouped]);
// Only the working files roll back; a commit's files are history.
const rollbackable = useMemo(() => {
const out = new Map<string, AgentChange>();
for (const root of rows) {
if (root.type === "file") {
if (!root.commit) out.set(root.key, root.change);
continue;
}
for (const child of root.children) {
if (child.type === "file" && !child.commit) out.set(child.key, child.change);
}
}
return out;
}, [rows]);
// Selection keys that no longer name a working file (a commit's file, a stale row) don't count.
const picked = [...selected].flatMap((key) => rollbackable.get(key) ?? []);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
const rollback = async () => {
const count = picked.length || changes?.length || 0;
const chosen = (changes ?? []).filter((c) => selected.has(c.path));
const count = chosen.length || changes?.length || 0;
if (!count) return;
const ok = await confirm({
title: "Rollback Changes",
@@ -2947,17 +2822,15 @@ function ChangesPane({
<>
<p>
{count === 1
? tx`Roll back the changes to ${basename((picked[0] ?? changes![0]!).path)}?`
? tx`Roll back the changes to ${basename((chosen[0] ?? changes![0]!).path)}?`
: tx`Roll back the changes to ${count} files?`}
</p>
<p>
{grouped
? tx("They go back to the last commit; changes made after it are lost.")
: task.isolation === "worktree"
? tx("They go back to how they were when the task started, in the task's worktree.")
: tx(
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.",
)}
{task.isolation === "worktree"
? tx("They go back to how they were when the task started, in the task's worktree.")
: tx(
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.",
)}
</p>
</>
),
@@ -2966,11 +2839,7 @@ function ChangesPane({
});
if (!ok) return;
try {
await rollbackChanges(
task.id,
picked.length ? picked.map((c) => c.relPath) : null,
grouped ? "group" : "sinceStart",
);
await rollbackChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null);
setSelected(new Set());
} catch (e) {
notifications().notify({
@@ -2981,15 +2850,10 @@ function ChangesPane({
}
};
const accept = async () => {
// Accepting brings over the task's whole work, committed or not: grouping only changes the listing.
const all =
grouped && !picked.length
? await call(commands.agentChanges(task.id)).catch(() => changes ?? [])
: (changes ?? []);
const targets = picked.length ? picked : all;
const count = targets.length;
const chosen = (changes ?? []).filter((c) => selected.has(c.path));
const count = chosen.length || changes?.length || 0;
if (!count) return;
const name = basename(targets[0]!.path);
const name = basename((chosen[0] ?? changes![0]!).path);
const ok = await confirm({
title: "Accept Changes",
message: (
@@ -3007,7 +2871,7 @@ function ChangesPane({
});
if (!ok) return;
try {
const outcome = await acceptChanges(task.id, picked.length ? picked.map((c) => c.relPath) : null);
const outcome = await acceptChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null);
setSelected(new Set());
if (outcome.skipped.length) {
const skippedNames = outcome.skipped.map((s) => s.path).join(", ");
@@ -3043,44 +2907,23 @@ function ChangesPane({
{task.isolation === "worktree" && (
<IconButton
icon="check"
title={picked.length ? "Accept Selected Changes…" : "Accept All Changes…"}
disabled={(!changes?.length && !commits.length) || task.status === "starting" || task.status === "running"}
title={selected.size ? "Accept Selected Changes…" : "Accept All Changes…"}
disabled={!changes?.length || task.status === "starting" || task.status === "running"}
onClick={() => void accept()}
/>
)}
<IconButton
icon="revert"
title={picked.length ? "Rollback Selected Changes…" : "Rollback All Changes…"}
title={selected.size ? "Rollback Selected Changes…" : "Rollback All Changes…"}
disabled={!changes?.length || task.status === "starting" || task.status === "running"}
onClick={() => void rollback()}
/>
<IconButton icon="refresh" title="Refresh" onClick={() => void refreshChanges(task.id)} />
{task.isolation === "worktree" && (
<IconButton
icon="eye"
title="View Options"
aria-haspopup="menu"
onClick={(e) =>
showContextMenu(e, [
{ kind: "header", label: "Group By" },
{
id: "groupByCommit",
label: "Commit",
checked: grouped,
run: () => {
setChangesOptions({ groupByCommit: !grouped });
void refreshChanges(task.id);
},
},
])
}
/>
)}
{!docked && <IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />}
</div>
{(grouped || head || task.base) && (
{(head ?? task.base) && (
<div className={styles.changesSummary}>
<T>vs</T> {grouped ? "HEAD" : (head ?? task.base)}
<T>vs</T> {head ?? task.base}
{changes && changes.length > 0 && (
<>
{" "}
@@ -3093,81 +2936,29 @@ function ChangesPane({
{changes === null ? (
<Spinner />
) : (
<Tree<ChangeRow>
key={grouped ? "grouped" : "flat"}
<Tree<AgentChange>
aria-label="Changed files"
roots={rows}
getKey={(n) => n.key}
getLabel={(n) =>
n.type === "file" ? basename(n.change.path) : n.type === "commit" ? n.commit.summary : n.label
}
getChildren={(n) => (n.type === "file" ? undefined : n.children)}
isExpandable={(n) => n.type !== "file" && n.children.length > 0}
defaultExpanded={grouped ? ["group:uncommitted", "group:committed"] : undefined}
activateExpandable={(n) => n.type === "commit"}
roots={changes}
getKey={(c) => c.path}
getLabel={(c) => basename(c.path)}
getChildren={() => undefined}
isExpandable={() => false}
selected={selected}
onSelectionChange={(sel, lead) => {
if (lead?.type === "file" && !lead.commit) setSelected(sel);
}}
onActivate={(n) => {
if (n.type === "file") {
if (n.commit) void openCommitDiff(task, n.commit, n.change.path);
else if (grouped) void openUncommittedDiff(task, n.change.path);
else void openChangeDiff(task, n.change.path);
} else if (n.type === "commit") {
void openCommitDiff(task, n.commit, n.commit.files[0]?.path ?? "");
}
}}
renderNode={(n) => {
if (n.type === "group") {
return (
<span className={styles.changeGroup}>
<span className={styles.changeGroupName}>
{n.label === "Uncommitted" ? <T>Uncommitted</T> : <T>Committed</T>}
</span>
<span className={styles.muted}>
{n.label === "Committed"
? tx(n.count === 1 ? "1 commit" : "{count} commits").replace("{count}", String(n.count))
: `${n.count} ${tx(n.count === 1 ? "file" : "files")}`}
</span>
<span className={styles.changeStat}>
{n.added > 0 && <span className={styles.added}>+{n.added}</span>}{" "}
{n.removed > 0 && <span className={styles.removed}>−{n.removed}</span>}
</span>
</span>
);
}
if (n.type === "commit") {
const stats = statsOf(n.commit.files);
return (
<span className={styles.changeCommit} title={n.commit.message}>
<Icon name="commit" size={14} />
<span className={styles.changeCommitHash}>{n.commit.shortId}</span>
<span className={styles.changeCommitSummary}>{n.commit.summary}</span>
{n.commit.timeMs != null && (
<span className={styles.changeCommitTime}>{formatSentAt(n.commit.timeMs)}</span>
)}
<span className={styles.changeStat}>
{stats.added > 0 && <span className={styles.added}>+{stats.added}</span>}{" "}
{stats.removed > 0 && <span className={styles.removed}>−{stats.removed}</span>}
</span>
</span>
);
}
return (
<span className={styles.change} title={n.change.relPath}>
<Icon name={fileIconName(basename(n.change.path), n.change.path)} size={16} />
<span className={styles.changeName} style={{ color: KIND_COLOR[n.change.kind] }}>
{basename(n.change.path)}
</span>
<span className={styles.changeDir}>{relativeDir(task.cwd, n.change.path)}</span>
<span className={styles.changeStat}>
{n.change.added > 0 && <span className={styles.added}>+{n.change.added}</span>}{" "}
{n.change.removed > 0 && <span className={styles.removed}>−{n.change.removed}</span>}
</span>
onSelectionChange={(sel) => setSelected(sel)}
onActivate={(c) => void openChangeDiff(task, c.path)}
renderNode={(c) => (
<span className={styles.change} title={c.relPath}>
<Icon name={fileIconName(basename(c.path), c.path)} size={16} />
<span className={styles.changeName} style={{ color: KIND_COLOR[c.kind] }}>
{basename(c.path)}
</span>
);
}}
<span className={styles.changeDir}>{relativeDir(task.cwd, c.path)}</span>
<span className={styles.changeStat}>
{c.added > 0 && <span className={styles.added}>+{c.added}</span>}{" "}
{c.removed > 0 && <span className={styles.removed}>−{c.removed}</span>}
</span>
</span>
)}
empty={
<div className={styles.changesEmpty}>
<T>No changes yet</T>
@@ -3199,56 +2990,3 @@ export async function openChangeDiff(task: AgentTask, path: string) {
);
await openDiff(withChain(diffs, paths.indexOf(path)));
}
/** Diff of `path` in the grouped view: `HEAD` ↔ the working file. */
function uncommittedDiffData(task: AgentTask, change: AgentChange): DiffData {
const left: DiffData["left"] =
change.kind === "added" || change.kind === "untracked"
? { type: "text", text: "", label: "(does not exist)", absent: true }
: {
type: "rev",
rev: "HEAD",
path: change.oldPath ? repositoryPath(task.cwd, change.oldPath) : undefined,
label: "HEAD",
};
const right: DiffData["right"] =
change.kind === "deleted"
? { type: "text", text: "", label: "(deleted)", absent: true }
: { type: "worktree", label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree" };
return { path: change.path, left, right };
}
/** Registration needed before a diff reads revisions inside the task's own worktree. */
async function ensureTaskRepo(task: AgentTask) {
// A worktree is its own repository, outside the project's: `vcs_file_at` reads it only once
// it is registered as an external log for this window.
if (task.isolation === "worktree") await call(commands.vcsOpenExternalLog(task.cwd)).catch(() => undefined);
}
/** Opens the diff of `path` among the task's uncommitted files (grouped view). */
async function openUncommittedDiff(task: AgentTask, path: string) {
const changes = useAgents.getState().groupedChanges.get(task.id)?.uncommitted ?? [];
const paths = changes.map((change) => change.path);
if (!paths.includes(path)) return;
await ensureTaskRepo(task);
await openDiff(
withChain(
changes.map((change) => uncommittedDiffData(task, change)),
paths.indexOf(path),
),
);
}
/** Opens the diff of one commit (`parent ↔ commit`), starting at `path`. */
async function openCommitDiff(task: AgentTask, commit: AgentCommit, path: string) {
const diffs = commit.files.map((file) =>
commitFileDiffData(
{ id: commit.id, shortId: commit.shortId, parents: commit.parent ? [commit.parent] : [], repo: task.cwd },
{ path: file.relPath, kind: file.kind, oldPath: file.oldPath },
),
);
if (!diffs.length) return;
await ensureTaskRepo(task);
const index = commit.files.findIndex((file) => file.path === path);
await openDiff(withChain(diffs, index < 0 ? 0 : index));
}
-36
Переглянути файл
@@ -1,36 +0,0 @@
/** Changes pane view options, persisted across sessions. */
import { create } from "zustand";
export interface ChangesOptions {
/** Files grouped by the commit that changed them; otherwise the flat list since the task started. */
groupByCommit: boolean;
}
/** IDEA-like default: the review view. */
export const DEFAULT_CHANGES_OPTIONS: ChangesOptions = { groupByCommit: true };
const KEY = "openidea.agents.changesView.v1";
function load(): ChangesOptions {
try {
const raw = localStorage.getItem(KEY);
const o = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
return {
groupByCommit: typeof o.groupByCommit === "boolean" ? o.groupByCommit : DEFAULT_CHANGES_OPTIONS.groupByCommit,
};
} catch {
return { ...DEFAULT_CHANGES_OPTIONS };
}
}
export const useChangesOptions = create<ChangesOptions>(() => load());
/** Updates the options and persists them. */
export function setChangesOptions(patch: Partial<ChangesOptions>) {
useChangesOptions.setState(patch);
try {
localStorage.setItem(KEY, JSON.stringify(useChangesOptions.getState()));
} catch {
// Storage unavailable: the options last for this session.
}
}
+1 -31
Переглянути файл
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendKeyAction } from "./Composer";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -43,7 +43,6 @@ vi.mock("@/platform/tauriCore", async (importOriginal) => ({
import { isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { usePlatform } from "@/platform/platformInfo";
import type { ProjectInfo } from "@/bindings";
import { defaultTaskProject } from "./project";
import { HomeEditor } from "./HomeEditor";
@@ -244,35 +243,6 @@ describe("New Task composer commands", () => {
});
});
describe("New Task composer Home / End", () => {
const os = usePlatform.getState().os;
afterEach(() => {
usePlatform.setState({ os });
vi.restoreAllMocks();
});
it("moves the caret to the line's start or end on macOS, extending the selection with Shift", () => {
usePlatform.setState({ os: "macos" });
const modify = vi.fn();
vi.spyOn(window, "getSelection").mockReturnValue({ modify } as unknown as Selection);
render(<HomeEditor />);
const box = screen.getByRole("textbox");
expect(fireEvent.keyDown(box, { key: "Home" })).toBe(false);
fireEvent.keyDown(box, { key: "End", shiftKey: true });
fireEvent.keyDown(box, { key: "End", metaKey: true });
expect(modify.mock.calls).toEqual([
["move", "backward", "lineboundary"],
["extend", "forward", "lineboundary"],
]);
});
it("leaves Home / End to the text box elsewhere", () => {
usePlatform.setState({ os: "windows" });
render(<HomeEditor />);
expect(fireEvent.keyDown(screen.getByRole("textbox"), { key: "Home" })).toBe(true);
});
});
describe("New Task composer paste", () => {
it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => {
render(<HomeEditor />);
-1
Переглянути файл
@@ -8,7 +8,6 @@ vi.mock("@/platform/ipc", async (original) => ({
commands: {
agentEntries: ipc.entries,
agentChanges: async () => ({ status: "ok", data: [] }),
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
},
}));
-1
Переглянути файл
@@ -15,7 +15,6 @@ vi.mock("@/platform/ipc", async (original) => ({
commands: {
agentEntries: async () => ({ status: "ok", data: entries }),
agentChanges: async () => ({ status: "ok", data: [] }),
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
agentRollback: ipc.rollback,
agentPrompt: ipc.prompt,
+2 -93
Переглянути файл
@@ -4,12 +4,11 @@ import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { EditorShownProvider } from "@/workbench/contributions";
import { hideContextMenu, useContextMenuStore } from "@/ui";
import type { AgentChange, AgentCommit, AgentEntry, AgentTask } from "@/bindings";
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import { AgentsView } from "./AgentsView";
import { SessionEditor, conversationRows } from "./SessionEditor";
import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model";
import { disconnect, useAgents } from "./store";
import { useChangesOptions } from "./changesOptions";
import { taskActions } from "./taskActions";
import { replyActions } from "./replyActions";
import { focusedSession } from "./sessionFocus";
@@ -19,8 +18,6 @@ import tagStyles from "@/ui/InlineTag.module.css";
const backend = vi.hoisted(() => ({
entries: new Map<string, AgentEntry[]>(),
changes: [] as AgentChange[],
grouped: { uncommitted: [] as AgentChange[], commits: [] as AgentCommit[] },
rolledBack: [] as Array<[string, string[] | null, string | null]>,
answers: [] as Array<[string, number, string | null]>,
fetched: [] as string[],
entryError: null as string | null,
@@ -54,11 +51,7 @@ vi.mock("@/platform/ipc", async (original) => ({
return { status: "ok", data: backend.entries.get(key) ?? [] };
},
agentChanges: async () => ({ status: "ok", data: backend.changes }),
agentGroupedChanges: async () => ({ status: "ok", data: backend.grouped }),
agentRollbackChanges: async (task: string, paths: string[] | null, view: string | null) => {
backend.rolledBack.push([task, paths, view]);
return { status: "ok", data: null };
},
agentRollbackChanges: async () => ({ status: "ok", data: null }),
agentAcceptChanges: async (task: string, paths: string[] | null) => {
backend.accepted.push([task, paths]);
return { status: "ok", data: backend.acceptedOutcome };
@@ -163,11 +156,7 @@ beforeEach(() => {
disconnect();
localStorage.clear();
backend.changes = [];
backend.grouped = { uncommitted: [], commits: [] };
backend.rolledBack = [];
backend.external = [];
// The flat list unless a test asks for the grouped view, so counts and selections stay as before.
useChangesOptions.setState({ groupByCommit: false });
backend.imported = [];
backend.externalError = null;
backend.accepted = [];
@@ -1211,7 +1200,6 @@ describe("session state across tasks and hosts", () => {
kind: "modified",
added: 8,
removed: 2,
oldPath: null,
},
];
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
@@ -1393,7 +1381,6 @@ describe("accept changes", () => {
kind: "modified",
added: 8,
removed: 2,
oldPath: null,
},
];
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
@@ -1429,7 +1416,6 @@ describe("accept changes", () => {
kind: "modified",
added: 8,
removed: 2,
oldPath: null,
},
];
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
@@ -1439,80 +1425,3 @@ describe("accept changes", () => {
expect(screen.getByRole("button", { name: "Rollback All Changes…" })).toBeInTheDocument();
});
});
describe("grouped changes", () => {
const commit = (overrides: Partial<AgentCommit> = {}): AgentCommit => ({
id: "a".repeat(40),
shortId: "aaaaaaa",
summary: "change a",
message: "change a\n\nbody",
timeMs: 1,
parent: "b".repeat(40),
files: [],
...overrides,
});
const change = (path: string): AgentChange => ({
path: `/project/${path}`,
relPath: path,
kind: "modified",
added: 1,
removed: 1,
oldPath: null,
});
const worktreeTask = () => {
useChangesOptions.setState({ groupByCommit: true });
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]) });
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
};
it("puts committed files under their commit and rolls back only the uncommitted ones", async () => {
backend.grouped = { uncommitted: [change("a.ts")], commits: [commit({ files: [change("b.ts")] })] };
worktreeTask();
render(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Uncommitted")).toBeInTheDocument();
expect(screen.getByText("Committed")).toBeInTheDocument();
expect(screen.getByText("aaaaaaa")).toBeInTheDocument();
expect(screen.getByText("change a")).toBeInTheDocument();
// The commit's files show once it is expanded; selecting one is not a rollback selection.
const commitRow = screen.getByText("change a").closest("[data-row]")!;
fireEvent.mouseDown(commitRow.querySelector("[data-tree-toggle]")!);
expect(await screen.findByText("b.ts")).toBeInTheDocument();
fireEvent.mouseDown(screen.getByText("b.ts"));
expect(screen.getByRole("button", { name: "Rollback All Changes…" })).toBeInTheDocument();
fireEvent.mouseDown(screen.getByText("a.ts"));
expect(screen.getByRole("button", { name: "Rollback Selected Changes…" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Rollback Selected Changes…" }));
await waitFor(() => expect(backend.confirmed).toEqual(["Rollback Changes"]));
expect(backend.rolledBack).toEqual([["A", ["a.ts"], "group"]]);
});
it("counts only uncommitted files in the header and the tray", async () => {
backend.grouped = {
uncommitted: [change("a.ts")],
commits: [commit({ files: [change("b.ts"), change("c.ts")] })],
};
worktreeTask();
render(<SessionEditor input={input("A")} />);
await screen.findByRole("complementary", { name: "Changes" });
expect(screen.getByRole("button", { name: /1 changed file/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /1 commit/ })).toBeInTheDocument();
});
it("accepts the task's committed work too, not only the uncommitted files", async () => {
backend.grouped = { uncommitted: [], commits: [commit({ files: [change("b.ts")] })] };
backend.changes = [change("b.ts")];
worktreeTask();
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Committed");
const accept = screen.getByRole("button", { name: "Accept All Changes…" });
expect(accept).toBeEnabled();
fireEvent.click(accept);
await waitFor(() => expect(backend.accepted).toEqual([["A", null]]));
});
});
+4 -34
Переглянути файл
@@ -5,8 +5,6 @@ import { create } from "zustand";
import type {
AgentAcceptOutcome,
AgentChange,
AgentChanges,
AgentChangesView,
AgentCommand,
AgentConfigOption,
AgentEntry,
@@ -18,7 +16,6 @@ import type {
ExternalSession,
Isolation,
} from "@/bindings";
import { useChangesOptions } from "./changesOptions";
import { basename, useProject } from "@/platform/project";
import { defaultTaskProject } from "./project";
import { call, commands, isTauri } from "@/platform/ipc";
@@ -80,8 +77,6 @@ interface AgentsState {
entries: Map<string, AgentEntry[]>;
entryLoads: Map<string, { error: string | null }>;
changes: Map<string, AgentChange[]>;
/** The task's uncommitted changes and commits, when the Changes pane groups by commit. */
groupedChanges: Map<string, AgentChanges>;
/** Scroll position and follow-output preference by conversation, shared by both hosts. */
scrollPositions: Map<string, { top: number; stick: boolean }>;
providers: AgentProvider[];
@@ -152,7 +147,6 @@ export const useAgents = create<AgentsState>(() => ({
entries: new Map(),
entryLoads: new Map(),
changes: new Map(),
groupedChanges: new Map(),
providers: [],
limits: new Map(),
draft: savedDraft(load<Partial<Draft>>(DRAFT_KEY, {})),
@@ -318,7 +312,6 @@ export async function connect(): Promise<void> {
entries: new Map(),
entryLoads: new Map(),
changes: new Map(),
groupedChanges: new Map(),
providers,
});
notifyTabTitlesChanged();
@@ -379,7 +372,6 @@ export function disconnect() {
entries: new Map(),
entryLoads: new Map(),
changes: new Map(),
groupedChanges: new Map(),
docked: null,
lastTask: null,
homeOpen: false,
@@ -530,23 +522,6 @@ export async function loadEntries(task: string, subagent: string | null = null):
}
export async function refreshChanges(task: string): Promise<void> {
// Local tasks have no commits of their own to group: the flat list is their only view.
const grouped =
useChangesOptions.getState().groupByCommit && useAgents.getState().tasks.get(task)?.isolation === "worktree";
if (grouped) {
try {
const review = await call(commands.agentGroupedChanges(task));
useAgents.setState((s) => ({
groupedChanges: new Map(s.groupedChanges).set(task, review),
}));
} catch {
// The worktree is gone or not a repository: nothing to list.
useAgents.setState((s) => ({
groupedChanges: new Map(s.groupedChanges).set(task, { uncommitted: [], commits: [] }),
}));
}
return;
}
try {
const changes = await call(commands.agentChanges(task));
useAgents.setState((s) => ({
@@ -558,14 +533,10 @@ export async function refreshChanges(task: string): Promise<void> {
}
}
/** Rollback: the task's changed files `relPaths` (all with `null`) back as `view` compares them. */
export async function rollbackChanges(
task: string,
relPaths: string[] | null,
view: AgentChangesView = "sinceStart",
): Promise<void> {
/** Rollback: the task's changed files `relPaths` (all with `null`) back as they were when it started. */
export async function rollbackChanges(task: string, relPaths: string[] | null): Promise<void> {
try {
await call(commands.agentRollbackChanges(task, relPaths, view));
await call(commands.agentRollbackChanges(task, relPaths));
} finally {
await refreshChanges(task);
}
@@ -581,8 +552,7 @@ export async function acceptChanges(task: string, relPaths: string[] | null): Pr
}
const pendingChanges = new Map<string, ReturnType<typeof setTimeout>>();
/** Reloads the task's changes after a short pause (tool diffs, commits landing). */
export function scheduleChanges(task: string) {
function scheduleChanges(task: string) {
if (pendingChanges.has(task)) return;
pendingChanges.set(
task,
-2
Переглянути файл
@@ -55,7 +55,6 @@ vi.mock("@/platform/ipc", async (original) => ({
},
agentProviders: async () => ({ status: "ok", data: [] }),
agentChanges: async () => ({ status: "ok", data: [] }),
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
agentBaseText: async (_task: string, path: string) => ({ status: "ok", data: `base of ${path}` }),
agentEntries: async () => ({ status: "ok", data: await backend.entries }),
},
@@ -414,7 +413,6 @@ describe("the diff of an agent change", () => {
kind: "modified" as const,
added: 1,
removed: 1,
oldPath: null,
});
it("lists the task's other changed files, so F7 moves on to the next one", async () => {
-25
Переглянути файл
@@ -80,25 +80,6 @@ interface Job {
cancel: () => void;
}
const SIZE_KEY = "openidea.newProject.size";
function loadDialogSize(): { w: number; h: number } | null {
try {
const v = JSON.parse(localStorage.getItem(SIZE_KEY) ?? "null") as { w?: unknown; h?: unknown } | null;
return typeof v?.w === "number" && typeof v.h === "number" ? { w: v.w, h: v.h } : null;
} catch {
return null;
}
}
function saveDialogSize(size: { w: number; h: number }) {
try {
localStorage.setItem(SIZE_KEY, JSON.stringify(size));
} catch {
// Not kept: the dialog opens at its default size next time.
}
}
function NewProjectDialog({ onClose }: { onClose: () => void }) {
const tx = useTx();
const txTemplate = useTxTemplate();
@@ -254,12 +235,6 @@ function NewProjectDialog({ onClose }: { onClose: () => void }) {
title="New Project"
onClose={onClose}
width={860}
resizable="both"
minWidth={720}
minHeight={480}
initialSize={loadDialogSize()}
onResized={saveDialogSize}
className={styles.dialog}
buttons={[
{ label: "Create", role: "default", disabled: invalid, onClick: () => void createProject() },
{ label: "Cancel", role: "cancel" },
+1 -4
Переглянути файл
@@ -1,11 +1,8 @@
/* File › New Project… */
.dialog {
height: min(602px, calc(100vh - 48px));
}
.layout {
display: grid;
grid-template-columns: 220px 1fr;
height: 100%;
height: 520px;
min-height: 0;
}
.sidebar {
-1
Переглянути файл
@@ -80,7 +80,6 @@ describe("build tools feature", () => {
"maven.analyzeDependencies",
"maven.executeGoal",
"maven.generateSources",
"maven.importExternal",
"maven.reload",
"maven.settings",
"maven.toggleOffline",
-14
Переглянути файл
@@ -35,7 +35,6 @@ import {
import {
ANALYZER_KIND,
addProjects,
importModuleFromExistingSources,
linkGradleProjects,
unlinkGradleProjects,
requestSync,
@@ -281,24 +280,11 @@ export function activate(): () => void {
{
id: "maven.addProjects",
title: "Add Maven Projects",
description:
"Choose one or more pom.xml files inside or outside the opened project. The opened folder stays the project root.",
category: "Maven",
icon: "plus",
when: "projectOpen && !workspaceOpen",
run: () => addProjects(),
},
{
id: "maven.importExternal",
title: "Import Module from Existing Sources",
description:
"Choose a module directory outside the opened project. If it contains pom.xml, that Maven project is attached. The opened folder stays the project root, and neither POM is edited.",
category: "Maven",
icon: "plus",
when: "projectOpen && !workspaceOpen",
keywords: ["import", "module", "existing", "sources", "external", "maven", "pom"],
run: () => importModuleFromExistingSources(),
},
{
id: "maven.unlinkProjects",
title: "Unlink Maven Projects",
+6 -119
Переглянути файл
@@ -10,7 +10,7 @@ import { GradleView, MavenView } from "./BuildToolView";
import { activate } from "./index";
import { expandableKeys, gradleChildren, gradleRoots, gradleProjectKey } from "./model";
import { executeBuildLine } from "./runAnything";
import { classifyPickedPoms, mavenPomForImport, useBuild } from "./store";
import { useBuild } from "./store";
const mocks = vi.hoisted(() => ({
get: vi.fn(),
@@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({
model: vi.fn(),
configs: vi.fn(),
open: vi.fn(),
readDir: vi.fn(),
confirm: vi.fn(),
menu: vi.fn(),
link: vi.fn(),
@@ -42,7 +41,6 @@ vi.mock("@/platform/ipc", async (original) => {
gradleLinkProjects: mocks.link,
gradleUnlinkProjects: mocks.unlink,
buildSync: mocks.sync,
fsReadDir: (path: string) => mocks.readDir(path),
cancelRequest: () => Promise.resolve(),
},
};
@@ -243,8 +241,8 @@ it("re-adds a picked POM using the plus button and preserves other unlinked root
);
});
it.each([null, "/p/a/build.xml", "/p/a/pom.xml", "not-a-file"])(
"ignores cancelled, invalid or already linked picks: %s",
it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/p/a/pom.xml"])(
"ignores cancelled, invalid, outside or already linked picks: %s",
async (picked) => {
mocks.open.mockResolvedValue(picked);
await executeAction("maven.addProjects");
@@ -252,76 +250,6 @@ it.each([null, "/p/a/build.xml", "/p/a/pom.xml", "not-a-file"])(
},
);
it("imports a module directory from Project Structure when that directory contains pom.xml", async () => {
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: [], linkedProjects: [] } } },
});
mocks.open.mockResolvedValue("/B");
mocks.readDir.mockResolvedValue([
{
name: "pom.xml",
path: "/B/pom.xml",
kind: "file",
size: 1,
mtimeMs: 1,
ignored: false,
excluded: false,
hidden: false,
},
]);
await act(async () => {
await executeAction("maven.importExternal");
});
expect(mocks.show).toHaveBeenCalledWith("maven");
expect(mocks.open).toHaveBeenCalledWith(
expect.objectContaining({
title: "Import Module from Existing Sources",
directory: true,
defaultPath: "/p",
}),
);
expect(mocks.readDir).toHaveBeenCalledWith("/B");
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", '["/B/pom.xml"]');
});
it("does not link a module directory that has no pom.xml", async () => {
mocks.open.mockResolvedValue("/B");
mocks.readDir.mockResolvedValue([]);
await act(async () => {
await executeAction("maven.importExternal");
});
expect(mocks.set).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({
level: "warning",
title: "Maven project was not imported",
body: expect.stringContaining("pom.xml"),
}),
);
});
it("maps a module directory or a pom.xml onto the same link", () => {
expect(mavenPomForImport("/code/B")).toBe("/code/B/pom.xml");
expect(mavenPomForImport("/code/B/pom.xml")).toBe("/code/B/pom.xml");
});
it("links a sibling pom.xml outside the opened project and collapses duplicate spellings", async () => {
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: [], linkedProjects: [] } } },
});
mocks.open.mockResolvedValue(["/p/../B/pom.xml", "/B/pom.xml", "/other/notes.xml"]);
await act(async () => {
await executeAction("maven.addProjects");
});
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", '["/B/pom.xml"]');
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({
level: "warning",
body: expect.stringContaining("/other/notes.xml"),
}),
);
});
it("links `\\\\?\\`-prefixed and differently-cased picks on Windows", async () => {
const os = usePlatform.getState().os;
usePlatform.setState({ os: "windows" });
@@ -344,8 +272,8 @@ it("links `\\\\?\\`-prefixed and differently-cased picks on Windows", async () =
}
});
it("warns about a pick that is not a pom.xml instead of silently doing nothing", async () => {
mocks.open.mockResolvedValue("/other/readme.xml");
it("warns about picks outside the project instead of silently doing nothing", async () => {
mocks.open.mockResolvedValue("/other/pom.xml");
await act(async () => {
await executeAction("maven.addProjects");
});
@@ -354,31 +282,11 @@ it("warns about a pick that is not a pom.xml instead of silently doing nothing",
expect.objectContaining({
level: "warning",
title: expect.stringContaining("not added"),
body: expect.stringContaining("/other/readme.xml"),
body: expect.stringContaining("/other/pom.xml"),
}),
);
});
it("classifies inside, sibling and lexical paths without a filesystem walk", () => {
expect(classifyPickedPoms("/p", ["/p/a/../stub/pom.xml", "/p/../B/pom.xml", "/B/pom.xml"])).toEqual({
link: ["stub/pom.xml", "/B/pom.xml"],
rejected: [],
});
expect(classifyPickedPoms("/p", ["/p/a/build.xml"])).toEqual({ link: [], rejected: ["/p/a/build.xml"] });
const os = usePlatform.getState().os;
usePlatform.setState({ os: "windows" });
try {
expect(
classifyPickedPoms("C:\\Projects\\demo", [
"\\\\?\\C:\\Projects\\demo\\..\\B\\pom.xml",
"c:/Projects/demo/svc/pom.xml",
]),
).toEqual({ link: ["C:/Projects/B/pom.xml", "svc/pom.xml"], rejected: [] });
} finally {
usePlatform.setState({ os });
}
});
it("links picked POMs that no reactor lists as independent top-level projects", async () => {
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: ["b/pom.xml"], linkedProjects: ["tools/pom.xml"] } } },
@@ -396,27 +304,6 @@ it("links picked POMs that no reactor lists as independent top-level projects",
);
});
it("unlinking an external project removes the link and does not delete it from disk", async () => {
mocks.get.mockResolvedValue({ languages: { java: { maven: { linkedProjects: ["/B/pom.xml"] } } } });
const external = {
...model.maven!.projects[0]!,
pom: "/B/pom.xml",
dir: "/B",
artifactId: "b",
displayName: "project-b",
};
const linked: BuildModel = { ...model, maven: { ...model.maven!, projects: [...model.maven!.projects, external] } };
mocks.model.mockResolvedValue(linked);
await act(async () => {
useBuild.setState({ model: linked });
});
await act(async () => {
await executeAction("maven.unlinkProjects", { pom: "/B/pom.xml" });
});
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", "[]");
expect(mocks.set).not.toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", expect.anything());
});
it("unlinking a linked independent project removes the link instead of hiding it", async () => {
mocks.get.mockResolvedValue({ languages: { java: { maven: { linkedProjects: ["a/pom.xml"] } } } });
await act(async () => {
-13
Переглянути файл
@@ -228,19 +228,6 @@ describe("Maven runs", () => {
});
expect(root.cwd).toBeNull();
expect(root.isTest).toBe(false);
const external = mavenGoalConfig(
project({
pom: "/code/B/pom.xml",
dir: "/code/B",
artifactId: "b",
displayName: "b",
command: "/code/B/mvnw",
}),
["compile"],
maven,
{ offline: false, skipTests: false, profiles: {} },
);
expect(external).toMatchObject({ command: "/code/B/mvnw", cwd: "/code/B", args: ["compile"] });
const configured = {
...core,
command: "//?/UNC/server/share/maven/bin/mvn.cmd",
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more