дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-12 20:56:00 +02:00
Порівняти коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
c5bccf020e |
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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 ¤t {
|
||||
Some(p) if project_scope => p.settings.set(scope, key, value)?,
|
||||
Some(p) => {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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")?) })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -430,9 +430,6 @@ impl Handler for ClientHandler {
|
||||
crate::kotlin_lsp::record_import_state(&self.server, self.generation, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
if self.server.running_gen(self.generation).is_some() {
|
||||
self.server.set_java_ready();
|
||||
}
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ProjectStatus" => {
|
||||
|
||||
@@ -201,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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>>> {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]]`
|
||||
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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]]`
|
||||
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = [] }
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [] }
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [] }
|
||||
@@ -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",
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()))
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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})`, () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,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 />);
|
||||
|
||||
@@ -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" } }),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -80,7 +80,6 @@ describe("build tools feature", () => {
|
||||
"maven.analyzeDependencies",
|
||||
"maven.executeGoal",
|
||||
"maven.generateSources",
|
||||
"maven.importExternal",
|
||||
"maven.reload",
|
||||
"maven.settings",
|
||||
"maven.toggleOffline",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
Reference in new issue
Заблокувати користувача