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

..
Автор SHA1 Повідомлення Дата
l0ng-ai a97907a2bd fix(plugins): list the official marketplace in debug builds too
Debug builds set no default marketplace index, so the Plugins page showed only the repository's local plugins and the marketplace flows (browse, install, update checks, revocations) could not be exercised without pasting the index URL by hand. The official index is now the default in every build; the local plugins stay in their own group.
2026-11-12 22:16:58 +08:00
l0ng-ai 405343b7b7 fix(code-insight): show a language server busy only once it stays busy (#2953)
jdtls reports a $/progress task on almost every edit, save, search and diagnostics publish, most lasting 0-20 ms, so the status bar's language-server dot kept switching to busy and back. A running server now shows busy only after it has been busy for 500 ms, the delay background tasks already use. Other statuses and the switch back to running still show at once; only the status bar display is delayed.
2026-11-12 21:47:39 +08:00
Muuтаmuutot cc29842e6b fix(python): workspace members inherit the project interpreter, and a deleted one falls back (#2920)
A workspace member attached to a project resolved its Python interpreter only from its own settings and folder, so a member with no Python of its own ran on the PATH python while the status widget named the primary project's interpreter. Members now inherit the primary project's interpreter when they have none (explicit setting, then virtualenv or pin, as a module without its own SDK inherits the project SDK), sdk_python_effective answers with the same chain, and the status widget asks about the open file's member.

A configured interpreter whose executable was deleted is skipped during resolution, so runs fall back to the project virtualenv or the inherited interpreter. Settings mark it Invalid (decided from what the backend resolves, so working custom paths are not flagged), the status widget warns when nothing resolves, and starting a Python configuration on a missing path names the cause.

Co-authored-by: muutot <[email protected]>
2026-11-12 21:45:37 +08:00
l0ng-ai de0f738bb2 fix(editor): keep Enter for new lines in a completion's snippet (#2967)
Completing an XML tag inserts the server's snippet <tag>$1</tag>$0. Enter moved to the next snippet field in every snippet, so it jumped to the end of the line instead of splitting the tag. Enter now moves between fields only in live templates and Emmet; in a completion's snippet Tab does and Enter inserts a line break.

Closes #2951
2026-11-12 21:25:54 +08:00
l0ng-ai d781df542a fix(lsp): answer Java usages without waiting for jdtls' background work (#2945)
A usages search that found nothing waited up to 10 s for every $/progress task to end before asking again, and jdtls reports background work (builds, validation) for long stretches, so Show Usages on a method nothing calls could take 10 s. jdtls' searches wait for its index themselves, so after ServiceReady an empty answer is final: it is neither waited on nor flagged as incomplete. Before ServiceReady, and for other servers, running progress still counts as indexing.

Refs #2770
2026-11-12 21:12:54 +08:00
SunZhiQiang abfc4cbade feat(agents): review a task's commits in the Changes pane (#2960)
A worktree task's Changes pane only listed the cumulative diff since the task started, so work the agent committed was mixed into the current changes. It now groups the task's work: uncommitted files compared with HEAD, and each commit made since the task started with the files it changed against its first parent. Double-clicking a commit or one of its files opens the parent-to-commit diff; commit files are history and are not rolled back. Rollback in this view restores files to HEAD. Accept still brings over the task's whole work, committed or not. View Options > Group By > Commit switches back to the flat since-start list; tasks in the project's own checkout keep that list.

Co-authored-by: panda-student <[email protected]>
2026-11-12 21:10:33 +08:00
arookieofcтаa-rookie-of-C-language 646e5f8364 fix(vcs): explain a mode-only change in the diff viewer (#2864)
A chmod-only change is a real modification: git status lists it and a commit records the new mode, so status keeps reporting it (core.fileMode=false still hides it). The diff viewer used to show "Contents are identical" with no explanation; when both texts are equal but the git modes differ it now says "File mode changed: 100644 → 100755". The modes come from a new vcs_file_mode command (index/tree entry mode for revisions, worktree permissions for the working copy).

Closes #2853

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-12 21:01:13 +08:00
Mr0Bread a3b48b78fd fix(editor): keep narrow editor groups usable with vertical tabs (#2962)
With tabs placed left or right, the tab column was sized to its longest label (140–260 px) and never shrank, so in a narrow split group a long file name left the editor almost no room. The column is now capped at 40% of its group; long labels truncate and keep the full path in the tooltip. Top and bottom placement are unchanged.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-12 20:55:15 +08:00
Ernstjan FreriksтаEjaYF 5e4fa861bb fix(agents): move the caret with Home and End in the agent composer on macOS (#2943)
WebKit maps Home / End in a text box to scrolling and leaves the caret where it is, so they did nothing in the agent composer on macOS. The composer now moves the caret to the start or end of the visual line (Shift extends the selection); with ⌘, Ctrl or Alt the keys keep their native behaviour, and other platforms are unchanged.

Co-authored-by: EjaYF <[email protected]>
2026-11-12 20:55:06 +08:00
hwlLL0703 068ce8e721 feat(maven): import external Maven modules from Project Structure (#2922)
A Maven project outside the opened root can be linked explicitly: Add Maven Projects accepts an external pom.xml, and Project Structure › Modules › Import Module from Existing Sources (also on the project root's context menu) imports a directory with a pom.xml. The linked project becomes an extra project-tree root and an independent Maven project with its own working directory and wrapper; links are stored as canonical paths, survive reloads and reopening, and unlinking removes only the association. The opened project's jdtls gets the external directory as an extra workspace folder, keeping automatic discovery under the opened root. Neither POM is edited.

Merged with main's aggregation fix: a project an aggregator lists later becomes its module only when the aggregator lies inside the opened root and is not on the same chain, so a linked sibling does not take over the opened project.

Closes #2884

Co-authored-by: hwlLL0703 <[email protected]>
2026-11-12 20:52:00 +08:00
l0ng-ai e5cc1ed712 fix(http-client): show the request in the tool window while it is sent (#2966)
The HTTP Client tool window opened only once a response arrived, so slow requests looked unanswered. It now opens as the request is sent, and the response pane shows the request line, a progress indicator and Stop Request until the response replaces it.

Fixes #2937
2026-11-12 20:40:59 +08:00
l0ng-ai abe6285629 feat(app): resizable New Project dialog (#2965)
The New Project dialog gets a resize grip and remembers its size; the template list and settings pane grow with it. It opens at the same default size as before.

Part of #2903
2026-11-12 20:36:00 +08:00
l0ng-ai 66a829c24c fix(maven): an aggregator in a subfolder holds the root POM it lists as ../ (#2963)
The root POM was found first as a top-level project and skipped when parent/pom.xml listed it again as <module>../</module>, and module paths kept their '..', so the Maven tool window showed the root module beside the aggregator. Module paths are normalised and a project an aggregator lists later becomes its module.

Fixes #2877
2026-11-12 20:32:00 +08:00
l0ng-ai 04f3f9434f fix(remote): serve Open in Browser and the built-in preview from the remote host (#2961)
In a remote window the built-in web server ran on the client and read the local file system, so URLs for remote files were 404s. The server now lives in the host: openidea-server answers web_url with its own server on the remote loopback, and the client forwards that port over the SSH or dev container connection and returns the forwarded URL. The Host check accepts any port with 127.0.0.1 / localhost, since the forwarded port can differ.

Fixes #2915
2026-11-12 20:18:11 +08:00
l0ng-ai 4330034403 perf(remote): list the deployment tree concurrently and report sync phases (#2959)
Sync before run listed the remote project one folder after another and checked each external classpath entry in turn, a round trip each, silently. Folders are now listed 16 at a time and classpath entries checked the same way; the run console reports the comparing / checking phases and the elapsed time. The walk still visits every folder, so results are unchanged.

Part of #2873
2026-11-12 20:05:43 +08:00
l0ng-ai de8afe31d2 fix(remote): finish SSH runs whose channel outlives the process (#2957)
A remote run finished only when its SSH channel closed, so a channel that outlived the process (a child holding the inherited stdout, or a server that never closes it) left the run, and Stop / Rerun, hanging. The exit status now ends the process once the channel has been quiet for a second, and Stop finishes the run locally when the channel still reports nothing 3 s after KILL.

Fixes #2872
2026-11-12 20:02:25 +08:00
l0ng-ai 579e5eee9b feat(languages): add Prisma (#2955)
Prisma schema files get highlighting, structure view and folding from the bundled tree-sitter-prisma-io grammar, a file icon, and code insight from @prisma/language-server (installable from the IDE). The language definition provides the `prisma` settings section the server reads before each validation; without it the server crashed.

Closes #2926
2026-11-12 20:00:13 +08:00
l0ng-ai 099ede3478 fix(vcs): Commit… from Local Changes includes the selected files and focuses the message (#2952)
With the modal commit interface, Commit… (⌘K) and Commit and Push… invoked from Local Changes include just the selected files, and the commit message field takes focus. Invoked elsewhere, the dialog starts with the default inclusion instead of the previous dialog's leftovers.

Fixes #2923
2026-11-12 19:33:15 +08:00
l0ng-ai fb269afdfa fix(lsp): run every server's fix-all on save, and give ESLint its codeActionOnSave setting (#2950)
Run code cleanup applied one source.fixAll action, so in TypeScript files vtsls's won and ESLint's was dropped; fix-all now runs once per server that offers it, each asked again after the previous edit. ESLint rejected every fix-all request because the built-in settings lacked codeActionOnSave; all language files attaching eslint now send { mode = "all", rules = [] }. Failed save actions are logged.

Fixes #2927
Fixes #2928
2026-11-12 19:23:59 +08:00
Alexanderтаformiat 435b00d893 feat(search): colour Find in Files preview matches with the editor's search result colour (#2949)
Matches in the Find in Files and Search Everywhere preview use the editor scheme's search result background (IntelliJ's SEARCH_RESULT_ATTRIBUTES, new token --bg-search-result with values for every built-in theme) instead of the lists' SearchMatch yellow, as a background only so syntax colours stay readable. SEARCH_RESULT_ATTRIBUTES now imports into the new token; --bg-search-match keeps coming from SearchMatch.

Closes #2858

Co-authored-by: formiat <[email protected]>
2026-11-12 19:20:34 +08:00
jiejie1133 14fe542617 fix(editor): leave the caret after a completion snippet without fields (#2939)
CodeMirror leaves the caret before an inserted snippet that has no fields, so accepting a field like `val` at `root.` put the caret before it. LSP snippets without tab stops, choices or unresolved variables now get a final ${0} stop; SQL functions without parameters had the same problem and get one too. Snippets with fields keep their selection.

Closes #2936

Co-authored-by: jiejie1133 <[email protected]>
2026-11-12 19:19:37 +08:00
persicaTaoтаpersicaTao 513762147a fix(db): do not require TLS when SQL Server SSL is disabled (#2938)
Tiberius defaults to EncryptionLevel::Required with rustls, and trust_cert only skips certificate checks, so a SQL Server data source with SSL = Disable still negotiated TLS and timed out against a server without encryption (or one rustls can't speak TLS with). Disable now selects NotSupported; Prefer still trusts the certificate, Require and Verify keep validated TLS.

Closes #2917

Co-authored-by: persicaTao <[email protected]>
2026-11-12 19:18:49 +08:00
l0ng-ai 489f55f52c fix(vcs): don't commit a file that was added to the index and then deleted (#2947)
A file added to the index and then deleted from disk is listed nowhere in the Commit view, but it stayed in the index copy the commit is made from, so committing other files committed it too. Such staged entries are now excluded like unchecked staged files.

Fixes #2908
2026-11-12 19:15:23 +08:00
140 змінених файлів з 3713 додано та 330 видалено

No files matched your search

Створено
+13
Переглянути файл
@@ -4443,6 +4443,7 @@ dependencies = [
"ide-tools",
"ide-vcs",
"ide-vfs",
"ide-web",
"ide-workspace",
"ignore",
"jsonschema",
@@ -4601,6 +4602,7 @@ dependencies = [
"tree-sitter-nix",
"tree-sitter-ocaml",
"tree-sitter-php",
"tree-sitter-prisma-io",
"tree-sitter-proto",
"tree-sitter-python",
"tree-sitter-ruby",
@@ -4726,6 +4728,7 @@ name = "ide-remote"
version = "0.1.43"
dependencies = [
"dunce",
"futures",
"globset",
"ide-core",
"ide-run",
@@ -10437,6 +10440,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-prisma-io"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35912d65a6adb07f781db46d843bf0464935e267da6ed192705516adafc7c24"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-proto"
version = "0.6.0"
+21 -5
Переглянути файл
@@ -1343,6 +1343,15 @@ impl AgentManager {
blocking(move || at.changes(&before)).await
}
/// The task's uncommitted changes and, for a worktree task, the commits made since it
/// started (newest first). Both are read against the same `HEAD`.
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
let task = self.task(id)?;
let at = task.base();
let before = task.state.lock().before.clone();
blocking(move || at.grouped_changes(&before)).await
}
/// Content of `path` (in the task's directory) when the task started.
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
let at = self.task(id)?.base();
@@ -1353,9 +1362,14 @@ impl AgentManager {
.await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
/// were when it started (files the user had changed then, as the user left them).
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as
/// `view` compares them: as they were when it started, or as they are in `HEAD`.
pub async fn rollback_changes(
&self,
id: &str,
rel_paths: Option<Vec<String>>,
view: AgentChangesView,
) -> IdeResult<()> {
let task = self.task(id)?;
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
@@ -1366,8 +1380,10 @@ impl AgentManager {
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = at.changes(&before)?;
let rev = at.rev()?;
let (changes, rev) = match view {
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
};
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
+40 -1
Переглянути файл
@@ -484,7 +484,8 @@ pub struct NewAgentTask {
pub base: Option<String>,
}
/// A file the task changed, relative to [`AgentTask::base_commit`].
/// A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
/// grouped view.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentChange {
@@ -493,6 +494,44 @@ pub struct AgentChange {
pub kind: ide_vcs::ChangeKind,
pub added: u32,
pub removed: u32,
/// Path before a rename; the left side of its diff reads this path.
pub old_path: Option<String>,
}
/// A commit made while the task ran, with the files it changed against its first parent.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentCommit {
pub id: String,
pub short_id: String,
pub summary: String,
pub message: String,
pub time_ms: f64,
/// First parent, or `None` for a root commit.
pub parent: Option<String>,
pub files: Vec<AgentChange>,
}
/// Which comparison a rollback of the task's changes uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentChangesView {
/// Everything since the commit the task started from.
#[default]
SinceStart,
/// The uncommitted files against `HEAD`; commits made since the start stay.
Group,
}
/// A task's uncommitted files and the commits made since it started.
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentChanges {
/// Files changed against `HEAD`, less those changed before the task started and untouched since.
pub uncommitted: Vec<AgentChange>,
/// Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
/// for tasks working in the project's own checkout.
pub commits: Vec<AgentCommit>,
}
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
+29 -8
Переглянути файл
@@ -2853,14 +2853,14 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
}
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()]), AgentChangesView::SinceStart).await.unwrap();
assert_eq!(read("other.txt"), "base\n");
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
// After a restart, from the saved copy.
mgr.shutdown();
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
assert!(!dir.join("new.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
@@ -2973,7 +2973,7 @@ async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
@@ -2996,7 +2996,7 @@ async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_ba
repo.git(&["reset", "-q", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}");
assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}");
@@ -3023,7 +3023,7 @@ async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
repo.git(&["checkout", "-q", "feature"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}");
assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}");
@@ -3048,7 +3048,7 @@ async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
repo.write("a.txt", "agent\nagain\n");
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it");
assert!(!repo.dir.join("b.txt").exists(), "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
@@ -3069,7 +3069,7 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
let listed = changed(&mgr, &task.id).await;
assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}");
mgr.rollback_changes(&task.id, Some(listed)).await.unwrap();
mgr.rollback_changes(&task.id, Some(listed), AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}");
assert!(!repo.dir.join("New.java").exists(), "{eol:?}");
assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}");
@@ -3082,6 +3082,27 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn the_group_view_rolls_back_only_uncommitted_files() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
repo.commit("agent work");
repo.write("a.txt", "agent\nagain\n");
let grouped = mgr.grouped_changes(&task.id).await.unwrap();
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"], "{eol:?}");
assert!(grouped.commits.is_empty(), "{eol:?}: local tasks have no commits to group");
mgr.rollback_changes(&task.id, None, AgentChangesView::Group).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to the start");
assert!(mgr.grouped_changes(&task.id).await.unwrap().uncommitted.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
for eol in EOLS {
@@ -3092,7 +3113,7 @@ async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_c
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}");
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert!(!repo.dir.join("new.txt").exists(), "{eol:?}");
assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
+180 -4
Переглянути файл
@@ -1,8 +1,8 @@
//! Task worktrees and the changes made in them.
use crate::model::AgentChange;
use crate::model::{AgentChange, AgentChanges, AgentCommit};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_vcs::{ChangeKind, Repo};
use ide_vcs::{ChangeKind, LogQuery, Repo};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
@@ -35,6 +35,14 @@ pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> {
Ok((info.head.unwrap_or_else(|| commit.clone()), commit))
}
/// `HEAD`'s commit in `cwd`, `None` outside a repository or before its first commit.
pub(crate) fn head_commit(cwd: &Path) -> IdeResult<Option<String>> {
match discover(cwd)? {
Some(repo) => Ok(repo.head()?.1),
None => Ok(None),
}
}
/// `git worktree add -b agent/<slug> <dir>/<slug> <base>`.
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
let repo = repo_at(project_root)?;
@@ -111,7 +119,14 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
.into_iter()
.map(|f| {
let (added, removed) = stat(&f.path);
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
AgentChange {
path: IpcPath(root.join(&f.path)),
rel_path: f.path,
kind: f.kind,
added,
removed,
old_path: f.old_path,
}
})
.collect();
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
@@ -140,6 +155,7 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
kind: ChangeKind::Deleted,
added: 0,
removed: 0,
old_path: None,
};
Some((f.path, source))
})
@@ -148,7 +164,57 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
fn added(path: IpcPath, rel_path: String) -> AgentChange {
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0, old_path: None }
}
/// The commits from `base` (exclusive) to `head` (inclusive) in `cwd`, newest first, each
/// with the files it changed against its first parent and their line counts.
pub(crate) fn commits(cwd: &Path, base: &str, head: &str) -> IdeResult<Vec<AgentCommit>> {
if base == head {
return Ok(Vec::new());
}
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
let root = repo.root().to_path_buf();
let query = LogQuery { range: Some(format!("{base}..{head}")), ..LogQuery::default() };
let mut out = Vec::new();
for commit in repo.log(&query)? {
let parent = commit.parents.first().cloned();
// A root commit has no parent to count lines against; its files still show.
let stats: std::collections::HashMap<String, (u32, u32)> = match &parent {
Some(parent) => repo
.diff_numstat(parent, &commit.id, &[])?
.into_iter()
.map(|s| (s.path, (s.added, s.removed)))
.collect(),
None => std::collections::HashMap::new(),
};
let mut files: Vec<AgentChange> = repo
.commit_files(&commit.id)?
.into_iter()
.map(|f| {
let (added, removed) = stats.get(&f.path).copied().unwrap_or((0, 0));
AgentChange {
path: IpcPath(root.join(&f.path)),
rel_path: f.path,
kind: f.kind,
added,
removed,
old_path: f.old_path,
}
})
.collect();
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
out.push(AgentCommit {
id: commit.id,
short_id: commit.short_id,
summary: commit.summary,
message: commit.message,
time_ms: commit.time_ms,
parent,
files,
});
}
Ok(out)
}
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
@@ -243,6 +309,34 @@ impl TaskBase {
.collect())
}
/// The task's uncommitted changes and, for a worktree task, the commits made since it
/// started (newest first). Both read the same `HEAD`, so a commit landing between the two
/// reads cannot put a file in both lists.
pub(crate) fn grouped_changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<AgentChanges> {
let cwd = self.cwd();
let Some(repo) = discover(cwd)? else { return Ok(AgentChanges::default()) };
let head = repo.head()?.1;
let uncommitted = match &head {
Some(head) => without_untouched(changes(cwd, head)?, before),
None => {
// No commits yet: every file is new, as in `changes` for a local task.
let mut all: Vec<AgentChange> = repo
.status()?
.into_iter()
.filter(|s| s.path.is_file())
.map(|s| added(s.path, s.rel_path))
.collect();
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
without_untouched(all, before)
}
};
let commits = match (self, &head) {
(Self::Worktree { cwd, base }, Some(head)) => commits(cwd, base, head)?,
_ => Vec::new(),
};
Ok(AgentChanges { uncommitted, commits })
}
/// The revision the task's changed files go back to on rollback, and are compared with:
/// the commit the task started from, or the current `HEAD` once the user committed on top
/// of it, so those commits stay. `None` while the repository has no commits.
@@ -598,6 +692,88 @@ mod tests {
assert!(!created.path.exists());
}
#[test]
fn commits_since_the_base_are_listed_with_their_files() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
assert!(commits(&created.path, &created.base_commit, &created.base_commit).unwrap().is_empty());
std::fs::write(created.path.join("a.txt"), "one\ntwo\n").unwrap();
git(&created.path, &["add", "."]);
git(&created.path, &["commit", "-q", "-m", "change a"]);
std::fs::write(created.path.join("b.txt"), "b\n").unwrap();
git(&created.path, &["add", "."]);
git(&created.path, &["commit", "-q", "-m", "add b"]);
let head = head_of(&created.path).unwrap().1;
let log = commits(&created.path, &created.base_commit, &head).unwrap();
assert_eq!(log.iter().map(|c| c.summary.as_str()).collect::<Vec<_>>(), ["add b", "change a"]);
assert_eq!(log[0].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["b.txt"]);
assert_eq!((log[0].files[0].added, log[0].files[0].removed), (1, 0));
assert_eq!(log[1].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
assert_eq!((log[1].files[0].added, log[1].files[0].removed), (1, 0));
assert!(log.iter().all(|c| c.parent.is_some() && !c.message.is_empty()));
// Nothing is uncommitted after the commits; editing a committed file brings it back.
let at = TaskBase::Worktree { cwd: created.path.clone(), base: created.base_commit.clone() };
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
assert!(grouped.uncommitted.is_empty());
assert_eq!(grouped.commits.len(), 2);
std::fs::write(created.path.join("a.txt"), "one\ntwo\nthree\n").unwrap();
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
assert_eq!(grouped.commits.len(), 2);
}
#[test]
fn a_rename_in_a_commit_keeps_its_old_path() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let base = head_of(&repo).unwrap().1;
std::fs::rename(repo.join("a.txt"), repo.join("b.txt")).unwrap();
std::fs::write(repo.join("b.txt"), "one\ntwo\nthree\nfour\nFIVE\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "rename a"]);
let head = head_of(&repo).unwrap().1;
let log = commits(&repo, &base, &head).unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].files.len(), 1);
assert_eq!(log[0].files[0].rel_path, "b.txt");
assert_eq!(log[0].files[0].old_path.as_deref(), Some("a.txt"));
assert_eq!(log[0].files[0].kind, ChangeKind::Renamed);
}
#[test]
fn a_local_task_has_no_commits() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
let at = TaskBase::Local { cwd: repo.clone(), start: None };
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
assert!(grouped.commits.is_empty());
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
}
#[test]
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
Переглянути файл
@@ -204,7 +204,7 @@ pub fn parse_settings(text: &str) -> Settings {
}
/// `a/b/../c` → `a/c`, without touching the filesystem.
fn normalize(p: &Path) -> PathBuf {
pub(crate) fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
+8
Переглянути файл
@@ -122,6 +122,14 @@ pub fn gradle_external_roots(
gradle::external_roots(root, dirs.iter().map(String::as_str))
}
/// Directories of Maven POMs linked from outside `root`, outermost only.
/// Reads just those explicit paths (no POM parse and no directory walk).
pub fn maven_external_roots(root: &Path, linked: &[String]) -> Vec<PathBuf> {
let dirs = maven::external_pom_dirs(root, linked);
let owned: Vec<String> = dirs.iter().map(|dir| dir.to_string_lossy().into_owned()).collect();
gradle::external_roots(root, owned.iter().map(String::as_str))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
pub fn gradle_source_roots(
root: &Path,
+337 -31
Переглянути файл
@@ -106,7 +106,9 @@ pub struct MavenProject {
pub profiles: Vec<String>,
/// Java feature version the project compiles for (`maven.compiler.release`, `java.version`…).
pub java_version: Option<u32>,
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
/// Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened
/// project the search stops at its root; outside it, the search stops before
/// that root so a linked project keeps its own wrapper.
pub command: String,
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
pub options: Vec<String>,
@@ -1032,10 +1034,6 @@ pub(crate) fn plugin(
const MAX_PROJECTS: usize = 1000;
fn rel_path(root: &Path, p: &Path) -> String {
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
}
/// The POMs that are not modules of another one: the root `pom.xml`, else the
/// shallowest `pom.xml` of each branch (a folder holding several builds). An
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
@@ -1049,18 +1047,29 @@ pub fn is_maven_project(root: &Path) -> bool {
!top_level_poms(root).is_empty()
}
/// The `mvnw` nearest to `dir`, looking up to `root`.
/// The `mvnw` nearest to `dir`. Inside `root` the walk stops at `root`. Outside it,
/// the walk stops before entering `root` or any ancestor of `root`: a sibling project
/// keeps its own wrapper, and the search does not continue to the filesystem root.
fn wrapper(root: &Path, dir: &Path) -> String {
let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
let mut cur = Some(dir);
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
let external = !dir.starts_with(&root);
let mut cur = Some(dir.as_path());
while let Some(d) = cur {
if d.join(name).is_file() {
return d.join(name).to_string_lossy().into_owned();
}
if d == root {
if !external && d == root {
break;
}
cur = d.parent();
let Some(parent) = d.parent() else { break };
// `parent` inside the opened project, or an ancestor of it (the common parent,
// then the filesystem root). Stop before inspecting those.
if external && (parent.starts_with(&root) || root.starts_with(parent)) {
break;
}
cur = Some(parent);
}
if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() }
}
@@ -1104,24 +1113,118 @@ pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[St
load_with_linked(root, local_repo, profiles, &[])
}
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
/// The `pom.xml` files `linked` names. Each entry is used as written (project-relative,
/// `../sibling/pom.xml`, or absolute) and canonicalized; a path that is not an existing
/// `pom.xml` is skipped. Nothing here lists a directory or walks parents looking for builds.
fn linked_poms(root: &Path, linked: &[String]) -> Vec<PathBuf> {
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut out: Vec<PathBuf> = Vec::new();
for pom in linked {
let pom = normalized_pom(pom);
if pom.is_empty() || pom.starts_with("../") || pom == ".." {
continue;
}
let path = root.join(&pom);
let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
if path.is_file() && inside && !out.contains(&path) {
let Some(path) = dunce::canonicalize(base.join(pom.trim())).ok() else { continue };
if is_pom_file(&path) && !out.contains(&path) {
out.push(path);
}
}
out
}
fn is_pom_file(path: &Path) -> bool {
path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" })
}
/// Identity of `path` (both canonical) within `root`: project-relative inside it,
/// otherwise the absolute path with forward slashes.
fn project_rel(root: &Path, path: &Path) -> String {
path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/")
}
/// Canonical `pom.xml` paths named by `linked`, keyed by the spelling to store and to
/// show so unlink removes the same entry settings hold. The first spelling wins.
fn linked_identities(root: &Path, linked: &[String]) -> HashMap<PathBuf, String> {
let mut out = HashMap::new();
for entry in linked {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let Some(path) = dunce::canonicalize(root.join(entry)).ok() else { continue };
if is_pom_file(&path) {
out.entry(path).or_insert_with(|| entry.replace('\\', "/"));
}
}
out
}
/// Stable, deduplicated form of `poms` for `languages.java.maven.linkedProjects`.
/// An existing `pom.xml` inside `root` is project-relative; one outside it is the
/// canonical absolute path. Spellings of the same file (`../B/pom.xml`, an absolute
/// path, a symlink) collapse to one entry. A path that exists and is not a `pom.xml`
/// is dropped. A `pom.xml` that cannot be resolved yet is kept, so a link is not
/// discarded while its volume is offline. No directory is scanned.
pub fn normalize_linked_poms(root: &Path, poms: &[String]) -> Vec<String> {
let Some(root) = dunce::canonicalize(root).ok() else {
let mut out = Vec::new();
for pom in poms {
let pom = normalized_pom(pom);
if !pom.is_empty() && pom_name_ok(&pom) && !out.contains(&pom) {
out.push(pom);
}
}
return out;
};
let mut out = Vec::new();
let mut seen = HashSet::new();
for pom in poms {
let pom = pom.trim();
if pom.is_empty() {
continue;
}
let key = match dunce::canonicalize(root.join(pom)) {
Ok(path) if is_pom_file(&path) => project_rel(&root, &path),
Ok(_) => continue,
Err(_) => {
let fallback = normalized_pom(pom);
let joined = root.join(pom);
if !pom_name_ok(&fallback) || (joined.exists() && !is_pom_file(&joined)) {
continue;
}
fallback
}
};
if key.is_empty() || !seen.insert(key.clone()) {
continue;
}
out.push(key);
}
out
}
fn pom_name_ok(path: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path);
if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" }
}
/// Directories of linked `pom.xml` files that sit outside `root` (the opened project
/// stays the root). Callers keep the outermost directory only. Modules a POM declares
/// outside its own directory are not included: a linked file cannot widen the indexed
/// tree to an arbitrary path.
pub fn external_pom_dirs(root: &Path, linked: &[String]) -> Vec<PathBuf> {
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut out = Vec::new();
for pom in linked_poms(root, linked) {
let Some(dir) = pom.parent() else { continue };
if dir.starts_with(&base) || out.iter().any(|have| have == dir) {
continue;
}
out.push(dir.to_path_buf());
}
out
}
/// Whether `root` holds a Maven build or the settings link one inside it.
pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
@@ -1130,7 +1233,9 @@ pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -
/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
/// dependencies and properties are part of the model, those turned off are not) and the
/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
/// top-level projects, including a `pom.xml` outside the opened root. A linked POM that
/// turns out to be a module of the root reactor stays that module. The same canonical file
/// is loaded once, so a cycle or a second spelling does not duplicate it or edit either POM.
pub fn load_with_linked(
root: &Path,
local_repo: Option<PathBuf>,
@@ -1139,7 +1244,10 @@ pub fn load_with_linked(
) -> MavenModel {
let mut resolver = Resolver::new(local_repo, profiles);
let mut model = MavenModel::default();
let mut seen: HashSet<PathBuf> = HashSet::new();
// Canonical POM → its project, for a module listed again by a later aggregator.
let mut seen: HashMap<PathBuf, Option<usize>> = HashMap::new();
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let linked_ids = linked_identities(&canonical_root, linked);
let mut tops = top_level_poms(root);
for pom in linked_poms(root, linked) {
if !tops.contains(&pom) {
@@ -1147,17 +1255,31 @@ pub fn load_with_linked(
}
}
// (pom, aggregated); the root reactor and its modules come before linked POMs.
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
while let Some((path, aggregated)) = stack.pop() {
// (pom, listed by): a module carries the top-level project its chain started from and
// whether the aggregator listing it lies inside the opened root.
let mut stack: Vec<(PathBuf, Option<(usize, bool)>)> = tops.into_iter().rev().map(|p| (p, None)).collect();
while let Some((path, listed_by)) = stack.pop() {
let aggregated = listed_by.is_some();
if model.projects.len() >= MAX_PROJECTS {
break;
}
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
if !seen.insert(canonical) {
if let Some(known) = seen.get(&canonical) {
// Found as a top-level POM first, then listed by an aggregator elsewhere
// (`<module>../</module>` of `parent/pom.xml`): it is that aggregator's module.
// Not when the chain leads back to where it started (a cycle), and not for an
// aggregator outside the opened root: a linked sibling does not take it over.
if let (Some(known), Some((origin, true))) = (*known, listed_by)
&& known != origin
{
model.projects[known].aggregated = true;
}
continue;
}
seen.insert(canonical.clone(), None);
let Some(pom) = resolver.pom(&path) else { continue };
let dir = path.parent().unwrap_or(root).to_path_buf();
let dir_canon = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
let ctx = resolver.context(&path, 0);
let props = resolver.properties_for(&pom, &ctx, &dir);
let i = |s: &str| interpolate(s, &props);
@@ -1168,7 +1290,7 @@ pub fn load_with_linked(
}
let mut modules: Vec<PathBuf> = Vec::new();
for m in module_names {
let mut mp = dir.join(i(&m));
let mut mp = crate::gradle::normalize(&dir.join(i(&m)));
if mp.is_dir() || mp.extension().is_none_or(|e| e != "xml") {
mp.push("pom.xml");
}
@@ -1232,28 +1354,39 @@ pub fn load_with_linked(
}
}
let artifact_id = i(&pom.artifact_id);
let module_ids: Vec<String> = modules
.iter()
.map(|m| {
let canonical = dunce::canonicalize(m).unwrap_or_else(|_| m.clone());
linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical))
})
.collect();
model.projects.push(MavenProject {
project_root: None,
pom: rel_path(root, &path),
dir: rel_path(root, &dir),
pom: linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical)),
dir: project_rel(&canonical_root, &dir_canon),
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
version: i(props.get("project.version").map(String::as_str).unwrap_or_default()),
display_name: pom.name.as_deref().map(i).filter(|n| !n.is_empty()).unwrap_or_else(|| artifact_id.clone()),
artifact_id,
packaging,
parent: pom.parent.as_ref().map(|p| format!("{}:{}:{}", p.group_id, p.artifact_id, i(&p.version))),
modules: modules.iter().map(|m| rel_path(root, m)).collect(),
modules: module_ids,
aggregated,
plugins,
dependencies,
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
java_version: jv,
command: wrapper(root, &dir),
command: wrapper(&canonical_root, &dir_canon),
options: Vec::new(),
env: BTreeMap::new(),
});
let index = model.projects.len() - 1;
seen.insert(canonical, Some(index));
let origin = listed_by.map_or(index, |(origin, _)| origin);
let inside = dir_canon.starts_with(&canonical_root);
for m in modules.into_iter().rev() {
stack.push((m, true));
stack.push((m, Some((origin, inside))));
}
}
model
@@ -1341,17 +1474,27 @@ impl UnlinkedProjects {
fn normalized_pom(pom: &str) -> String {
let pom = pom.replace('\\', "/");
let mut parts = Vec::new();
let unc = pom.starts_with("//");
let absolute = pom.starts_with('/');
let mut parts: Vec<&str> = Vec::new();
for part in pom.split('/') {
match part {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
// Keep a drive letter (`C:`) and a leading `..` that has nothing to climb.
".." if parts.last().is_some_and(|p| *p != ".." && !p.ends_with(':')) => {
parts.pop();
}
part => parts.push(part),
}
}
parts.join("/")
let joined = parts.join("/");
if unc {
format!("//{joined}")
} else if absolute {
format!("/{joined}")
} else {
joined
}
}
/// Every POM of the model (for change detection).
@@ -2147,6 +2290,169 @@ mod tests {
assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
}
/// A sibling Maven project is linked beside the opened root. Its reactor, including a
/// module that points back at the opened project, is read once. Neither POM is rewritten,
/// and only the linked project's directory is an extra content root — not the common
/// parent and not a module that lives outside that directory.
#[test]
fn external_sibling_keeps_its_directory_and_does_not_edit_poms() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let a = base.join("A");
let b = base.join("B");
let c = base.join("C");
std::fs::create_dir_all(a.join("core")).unwrap();
std::fs::create_dir_all(b.join("mod")).unwrap();
std::fs::create_dir_all(&c).unwrap();
let a_pom = "<project><groupId>demo</groupId><artifactId>a</artifactId><version>1</version><packaging>pom</packaging><modules><module>core</module></modules></project>";
let b_pom = "<project><groupId>demo</groupId><artifactId>b</artifactId><version>1</version><packaging>pom</packaging><modules><module>mod</module><module>../A</module><module>../C</module><module>.</module></modules></project>";
let c_pom = "<project><groupId>demo</groupId><artifactId>c</artifactId><version>1</version><modules><module>../B</module></modules></project>";
std::fs::write(a.join("pom.xml"), a_pom).unwrap();
std::fs::write(a.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
std::fs::write(b.join("pom.xml"), b_pom).unwrap();
std::fs::write(b.join("mod/pom.xml"), "<project><artifactId>bmod</artifactId></project>").unwrap();
std::fs::write(c.join("pom.xml"), c_pom).unwrap();
let wrapper = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
std::fs::write(a.join(wrapper), "#!/bin/sh\n").unwrap();
std::fs::write(b.join(wrapper), "#!/bin/sh\n").unwrap();
std::fs::write(base.join(wrapper), "#!/bin/sh\n").unwrap();
let discovered = load_with_linked(&a, None, &[], &[]);
assert_eq!(discovered.projects.iter().map(|p| p.artifact_id.as_str()).collect::<Vec<_>>(), ["a", "core"]);
let stored = vec!["../B/pom.xml".into()];
let linked = load_with_linked(&a, None, &[], &stored);
let b_project = linked.projects.iter().find(|p| p.artifact_id == "b").unwrap();
assert!(!b_project.aggregated);
assert_eq!(b_project.pom, "../B/pom.xml");
assert_eq!(dunce::canonicalize(a.join(&b_project.dir)).unwrap(), b);
assert_eq!(dunce::canonicalize(&b_project.command).unwrap(), dunce::canonicalize(b.join(wrapper)).unwrap());
let a_project = linked.projects.iter().find(|p| p.artifact_id == "a").unwrap();
assert!(!a_project.aggregated);
assert_eq!(a_project.modules, ["core/pom.xml"]);
assert_eq!(dunce::canonicalize(&a_project.command).unwrap(), dunce::canonicalize(a.join(wrapper)).unwrap());
assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "a").count(), 1);
assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "b").count(), 1);
assert!(linked.projects.iter().any(|p| p.artifact_id == "c" && p.aggregated));
assert!(linked.projects.iter().any(|p| p.artifact_id == "bmod" && p.aggregated));
let again = load_with_linked(&a, None, &[], &stored);
assert_eq!(
again.projects.iter().map(|p| &p.pom).collect::<Vec<_>>(),
linked.projects.iter().map(|p| &p.pom).collect::<Vec<_>>()
);
let absolute = b.join("pom.xml").to_string_lossy().replace('\\', "/");
let via_a = a.join("../B/pom.xml").to_string_lossy().replace('\\', "/");
let normalized = normalize_linked_poms(
&a,
&[
"../B/pom.xml".into(),
absolute,
via_a,
a.join("pom.xml").to_string_lossy().into_owned(),
b.to_string_lossy().into_owned(),
"stub/pom.xml".into(),
"notes.xml".into(),
],
);
assert_eq!(normalized.len(), 3, "{normalized:?}");
assert_eq!(normalized[0], dunce::canonicalize(b.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/"));
assert_eq!(normalized[1], "pom.xml");
assert_eq!(normalized[2], "stub/pom.xml");
assert_eq!(crate::maven_external_roots(&a, &normalized), vec![b.clone()]);
assert_eq!(crate::maven_external_roots(&a, &stored), vec![b.clone()]);
let removed = load_with_linked(&a, None, &[], &[]);
assert!(removed.projects.iter().all(|p| p.artifact_id != "b"));
assert_eq!(std::fs::read_to_string(a.join("pom.xml")).unwrap(), a_pom);
assert_eq!(std::fs::read_to_string(b.join("pom.xml")).unwrap(), b_pom);
assert_eq!(std::fs::read_to_string(c.join("pom.xml")).unwrap(), c_pom);
assert!(b.join("mod/pom.xml").is_file());
}
/// The launcher recorded on an external project is that project's wrapper, and
/// running it uses the external directory as the working directory.
#[cfg(unix)]
#[test]
fn external_project_wrapper_runs_in_its_own_directory() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let a = base.join("A");
let b = base.join("B");
std::fs::create_dir_all(b.join("src/main/java")).unwrap();
std::fs::create_dir_all(&a).unwrap();
std::fs::write(a.join("pom.xml"), "<project><artifactId>a</artifactId></project>").unwrap();
std::fs::write(
b.join("pom.xml"),
"<project><artifactId>b</artifactId><build><sourceDirectory>src/main/java</sourceDirectory></build></project>",
)
.unwrap();
std::fs::write(b.join("src/main/java/B.java"), "class B {}").unwrap();
let script = b.join("mvnw");
std::fs::write(&script, "#!/bin/sh\npwd > \"$1\"\nprintf 'BUILD SUCCESS\\n'\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let model = load_with_linked(&a, None, &[], &["../B/pom.xml".into()]);
let project = model.projects.iter().find(|p| p.artifact_id == "b").unwrap();
let cwd = a.join(&project.dir);
assert_eq!(dunce::canonicalize(&cwd).unwrap(), b);
let marker = tmp.path().join("cwd");
let out = crate::process::run(
&project.command,
&[marker.to_string_lossy().into_owned()],
&cwd,
&std::collections::BTreeMap::new(),
&ide_core::CancellationToken::new(),
|_, _| {},
)
.unwrap();
assert!(out.success, "{}", out.text);
let recorded = dunce::canonicalize(std::fs::read_to_string(&marker).unwrap().trim()).unwrap();
assert_eq!(recorded, b);
}
/// #2877: an aggregator in a subfolder lists the root POM as `../`; linked, it holds every
/// module, the root one included, under the same POM paths the projects have.
#[test]
fn an_aggregator_in_a_subfolder_holds_the_root_pom() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let child = |id: &str, rel: &str| {
format!(
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><relativePath>{rel}</relativePath></parent><artifactId>{id}</artifactId></project>"
)
};
write(root, "pom.xml", &child("app", "parent/pom.xml"));
write(
root,
"parent/pom.xml",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>../</module><module>../infra</module></modules></project>",
);
write(root, "infra/pom.xml", &child("infra", "../parent/pom.xml"));
let model = load_with_linked(root, None, &[], &["parent/pom.xml".into()]);
let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect();
assert_eq!(tops, ["parent"]);
let parent = model.projects.iter().find(|p| p.artifact_id == "parent").unwrap();
assert_eq!(parent.modules, ["pom.xml", "infra/pom.xml"]);
for pom in &parent.modules {
assert!(model.projects.iter().any(|p| &p.pom == pom), "{pom}");
}
// A cycle (the root lists the aggregator, which lists the root) keeps the root on top.
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>parent</module></modules></project>",
);
let model = load_with_linked(root, None, &[], &[]);
let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect();
assert_eq!(tops, ["app"]);
}
#[test]
fn chosen_profiles_bring_their_modules_and_dependencies() {
let tmp = tempfile::tempdir().unwrap();
+3 -1
Переглянути файл
@@ -1182,7 +1182,9 @@ pub struct MavenSettings {
pub runner_jre: String,
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
/// POMs added as independent top-level projects. Inside the opened project the path is
/// project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking
/// does not edit either POM.
pub linked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
+38 -3
Переглянути файл
@@ -283,6 +283,16 @@ fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
}
}
/// Tiberius requires TLS when a TLS backend is enabled. `trust_cert` only skips
/// certificate checks, so Disable has to turn encryption off.
fn apply_sqlserver_ssl(config: &mut tiberius::Config, ssl: DbSslMode) {
match ssl {
DbSslMode::Disable => config.encryption(tiberius::EncryptionLevel::NotSupported),
DbSslMode::Prefer => config.trust_cert(),
DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull => {}
}
}
const DAYS_1900_01_01: i64 = 693_595;
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
@@ -876,9 +886,7 @@ impl Pool {
if !source.database.is_empty() {
config.database(&source.database);
}
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
config.trust_cert();
}
apply_sqlserver_ssl(&mut config, source.ssl);
let manager = MssqlConnectionManager(config);
let pool = bb8::Pool::builder()
.max_size(MAX)
@@ -1331,6 +1339,33 @@ mod tests {
use super::*;
use crate::source::{DbOptions, DbProperty};
fn sqlserver_debug(ssl: DbSslMode) -> String {
let mut config = tiberius::Config::new();
apply_sqlserver_ssl(&mut config, ssl);
format!("{config:?}")
}
#[test]
fn sqlserver_disable_does_not_require_tls() {
let debug = sqlserver_debug(DbSslMode::Disable);
assert!(debug.contains("encryption: NotSupported"), "{debug}");
assert!(debug.contains("bypass: false"), "{debug}");
}
#[test]
fn sqlserver_prefer_trusts_the_server_certificate() {
let debug = sqlserver_debug(DbSslMode::Prefer);
assert!(debug.contains("encryption: Required"), "{debug}");
assert!(debug.contains("bypass: true"), "{debug}");
}
#[test]
fn sqlserver_require_keeps_validated_tls() {
let debug = sqlserver_debug(DbSslMode::Require);
assert!(debug.contains("encryption: Required"), "{debug}");
assert!(debug.contains("bypass: false"), "{debug}");
}
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
+1
Переглянути файл
@@ -46,6 +46,7 @@ globset.workspace = true
ide-db.workspace = true
ide-ssh.workspace = true
ide-remote.workspace = true
ide-web.workspace = true
ide-jsonrpc.workspace = true
ide-agent.workspace = true
ide-notebook.workspace = true
+4
Переглянути файл
@@ -84,6 +84,9 @@ pub struct Host {
pub remote: Arc<ide_remote::RemoteService>,
/// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`).
pub environment: environment::Environment,
/// The built-in web server (Open In › Browser, the editor's browser bar). On a remote host the
/// client forwards its ports.
pub web: ide_web::WebServer,
/// The HTTP proxy's saved password (see `proxy.rs`).
proxy_password: ide_ssh::secrets::SecretFile,
/// The proxy password when it could not be saved (no credential store).
@@ -140,6 +143,7 @@ impl Host {
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
web: ide_web::WebServer::new(),
global_settings,
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
data_dir.join("cache/dependency-versions"),
+20 -7
Переглянути файл
@@ -5,9 +5,9 @@ use crate::Host;
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
NewAgentTask,
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -304,16 +304,28 @@ pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<A
host.project(window)?.agents.changes(&task).await
}
/// The task's uncommitted changes and the commits made since it started.
pub async fn grouped_changes(host: &Host, window: &str, task: String) -> IdeResult<AgentChanges> {
host.require_trust(window)?;
host.project(window)?.agents.grouped_changes(&task).await
}
/// Content of `path` when the task started (left side of its diff).
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
host.require_trust(window)?;
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
}
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option<Vec<String>>) -> IdeResult<()> {
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
pub async fn rollback_changes(
host: &Host,
window: &str,
task: String,
paths: Option<Vec<String>>,
view: Option<AgentChangesView>,
) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.rollback_changes(&task, paths).await
host.project(window)?.agents.rollback_changes(&task, paths, view.unwrap_or_default()).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
@@ -476,9 +488,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await },
);
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_grouped_changes", |c| async move { grouped_changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
d.add("agent_rollback_changes", |c| async move {
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?, c.arg("view")?).await
});
d.add("agent_accept_changes", |c| async move {
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
+1
Переглянути файл
@@ -35,6 +35,7 @@ pub mod syntax;
pub mod term;
pub mod tools;
pub mod vcs;
pub mod web;
use crate::Host;
use ide_core::{IdeError, IdeResult};
+12 -1
Переглянути файл
@@ -164,10 +164,21 @@ pub fn set_settings(
// A LightEdit window has no project layer: its values are global.
let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light);
let is_proxy = key == "proxy" || key.starts_with("proxy.");
let Some(value) = host.take_proxy_password(key, value)? else {
let Some(mut value) = host.take_proxy_password(key, value)? else {
host.apply_proxy();
return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None });
};
// One canonical spelling per linked POM, including a project outside the opened root.
if project_scope
&& key == "languages.java.maven.linkedProjects"
&& let Some(project) = current.as_ref()
&& let Some(list) = value.as_array()
{
let poms: Vec<String> = list.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect();
if poms.len() == list.len() {
value = serde_json::json!(ide_build::maven::normalize_linked_poms(&project.root, &poms));
}
}
let settings = match &current {
Some(p) if project_scope => p.settings.set(scope, key, value)?,
Some(p) => {
+48 -8
Переглянути файл
@@ -3,6 +3,7 @@ use crate::Host;
use ide_core::remote_settings::RunTargetKind;
use ide_core::{IdeError, IdeResult, RunId};
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
use std::path::Path;
use std::time::Duration;
/// Detected configs followed by user-defined ones (user ids win on conflict).
@@ -138,11 +139,24 @@ pub fn save_configs(
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
}
/// The project `config` runs in (a workspace member, by its working directory) and its settings.
/// The project `config` runs in (a workspace member, by its working directory) and its
/// settings, with the primary project as the inherited fallback: a member with no Python
/// of its own runs on the primary project's interpreter (see `apply_to_run_configs_in`).
fn settings_for(
host: &Host,
project: &crate::project::Project,
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings, Option<std::path::PathBuf>)> {
let (root, settings) = settings_only(host, project, config)?;
let fallback = (root != project.root).then_some(project.root.clone());
Ok((root, settings, fallback))
}
/// [`settings_for`] without the fallback, for callers that only rebase or read settings.
fn settings_only(
host: &Host,
project: &crate::project::Project,
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
let owner = ide_run::scope::owner(&project.root, config);
@@ -180,8 +194,8 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
if c.kind == "gradle" {
return Ok(c);
}
let (root, settings) = settings_for(host, &project, &mut c)?;
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
Ok(ide_sdk::apply::without_derived_in(&c, &root, fallback.as_deref(), &settings))
})
.collect()
}
@@ -194,8 +208,13 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
.into_iter()
.map(|mut c| {
if c.kind != "gradle" {
let (root, settings) = settings_for(host, &project, &mut c)?;
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
ide_sdk::apply::apply_to_run_configs_in(
std::slice::from_mut(&mut c),
&root,
fallback.as_deref(),
&settings,
);
}
Ok(c)
})
@@ -329,7 +348,7 @@ async fn start_inner(
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
}
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
let (root, settings, fallback) = settings_for(host, &project, &mut config)?;
let root = &root;
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
// Maven re-applies too: a config listed before a settings change (daemon toggle,
@@ -338,17 +357,38 @@ async fn start_inner(
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat" | "junit") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
ide_sdk::apply::apply_to_run_configs_in(
std::slice::from_mut(&mut config),
root,
fallback.as_deref(),
&settings,
);
} else {
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
}
if config.kind == "go" {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
ide_sdk::apply::apply_to_run_configs_in(
std::slice::from_mut(&mut config),
root,
fallback.as_deref(),
&settings,
);
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
}
if config.command == ide_sdk::godot::COMMAND {
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
}
// A Python configuration pointing at a missing interpreter fails in the depth of
// process spawn otherwise; name the cause and the fix like PyCharm's "Invalid SDK".
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
&& !Path::new(&config.command).is_file()
&& Path::new(&config.command).components().count() > 1
{
return Err(IdeError::invalid(format!(
"The configured Python interpreter does not exist: {}\nSelect a working interpreter in Settings › Languages & Frameworks › Python Interpreter.",
config.command
)));
}
if config.kind == "tomcat" {
if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
return Err(IdeError::invalid("This Tomcat configuration is already running"));
+8 -1
Переглянути файл
@@ -69,7 +69,14 @@ pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) ->
}
None => (project.root.clone(), project.settings.effective()),
};
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
// A member with no Python of its own falls back to the primary project's
// (the same chain run configurations use), so every answer names one interpreter.
let interpreter = ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).or_else(|| {
(root != project.root)
.then(|| ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()))
.flatten()
});
Ok(interpreter.map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
+20
Переглянути файл
@@ -373,6 +373,25 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
.await
}
/// The file's git mode at `rev` (`100644`, `100755`, `120000`…); `"WT"` reads the
/// worktree's. `None` when the path is absent on that side. The diff viewer shows
/// "File mode changed" when the texts compare equal but the modes differ.
pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
}
external
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.file_mode_at(&path, rev.as_deref())
})
.await
}
pub async fn vcs_blame(
host: &Host,
window: &str,
@@ -1392,6 +1411,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
d.add("vcs_file_mode", |c| async move { vcs_file_mode(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
d.add("vcs_blame", |c| async move {
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
});
+11
Переглянути файл
@@ -0,0 +1,11 @@
//! The built-in web server's URLs.
use crate::Host;
use ide_core::IdeResult;
use std::path::Path;
/// The URL serving `file`, under the window's project when it holds the file.
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
let root = host.project(window).ok().map(|p| p.root.clone());
host.web.url_for(root.as_deref(), file).await
}
+35 -3
Переглянути файл
@@ -142,6 +142,10 @@ impl Host {
crate::plugins::injections::configure_lsp(&lsp);
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
lsp.set_content_roots(extra_roots.clone());
lsp.set_jdtls_import_roots(ide_build::maven_external_roots(
&root,
&settings.effective().languages.java.maven.linked_projects,
));
let settings = Arc::new(settings);
{
let settings = settings.clone();
@@ -1120,6 +1124,10 @@ impl Project {
}
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
self.lsp.set_content_roots(roots.clone());
self.lsp.set_jdtls_import_roots(ide_build::maven_external_roots(
&self.root,
&self.settings.effective().languages.java.maven.linked_projects,
));
self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone());
self.watcher.set_excludes(&excludes);
let mut watchers = self.content_watchers.lock();
@@ -1239,8 +1247,9 @@ pub(crate) fn workspace_member_roots(root: &Path) -> Vec<PathBuf> {
.unwrap_or_default()
}
/// Workspace members in manifest order, or extra Project Structure directories and the
/// Gradle project folders outside the project as of the last sync (`build_cache`).
/// Workspace members in manifest order, or extra Project Structure directories, Gradle
/// project folders outside the project, and directories of Maven POMs linked from
/// outside it (`build_cache` is the Gradle sync cache).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
if workspace.is_container(root) {
@@ -1332,7 +1341,10 @@ fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, bui
.iter()
.chain(settings.project_structure.dependencies.iter())
.map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
for p in user
.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle))
.chain(ide_build::maven_external_roots(root, &settings.languages.java.maven.linked_projects))
{
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
@@ -1768,6 +1780,26 @@ mod maven_linkage_tests {
assert_eq!(super::jdtls_maven_import(root, &settings).1, None);
}
#[test]
fn external_maven_link_is_a_content_root_and_not_its_sibling_module() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let a = base.join("A");
let b = base.join("B");
let c = base.join("C");
for dir in [&a, &b, &c] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("pom.xml"), "<project/>").unwrap();
}
let mut settings = ide_core::settings::Settings::default();
settings.languages.java.maven.linked_projects = vec!["../B/pom.xml".into()];
assert_eq!(super::content_roots(&settings, &a, &base.join("cache")), vec![b]);
// External links must not replace jdtls's scan of the opened root with a build-file list.
assert_eq!(super::jdtls_maven_import(&a, &settings), (Vec::new(), None));
settings.languages.java.maven.linked_projects.clear();
assert!(super::content_roots(&settings, &a, &base.join("cache")).is_empty());
}
#[test]
fn unlinked_roots_remain_plain_files() {
let tmp = tempfile::tempdir().unwrap();
+1
Переглянути файл
@@ -265,6 +265,7 @@ impl Dispatcher {
ops::run::start_request(&c.host, &c.window, c.arg("config")?, on_output, c.arg("request")?).await
})
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
.add("web_url", |c| async move { ops::web::url(&c.host, &c.window, &c.arg::<PathBuf>("path")?).await })
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
+71
Переглянути файл
@@ -0,0 +1,71 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use ide_workspace::settings::SettingsScope;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_maven_link_persists_reloads_and_unlink_keeps_files() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("A");
let sibling = base.join("B");
std::fs::create_dir_all(root.join("core")).unwrap();
std::fs::create_dir_all(&sibling).unwrap();
let a_pom = "<project><artifactId>a</artifactId><packaging>pom</packaging><modules><module>core</module></modules></project>";
let b_pom = "<project><artifactId>b</artifactId></project>";
std::fs::write(root.join("pom.xml"), a_pom).unwrap();
std::fs::write(root.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
std::fs::write(sibling.join("pom.xml"), b_pom).unwrap();
let config = base.join("config");
let host = Host::new(HostConfig {
config_dir: config.clone(),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let written =
serde_json::to_string(&vec!["../B/pom.xml".to_owned(), sibling.join("pom.xml").to_string_lossy().into_owned()])
.unwrap();
ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", &written)
.unwrap();
let saved =
|| ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.maven.linked_projects;
let canonical = dunce::canonicalize(sibling.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/");
assert_eq!(saved(), vec![canonical.clone()]);
let model = ops::build::build_model(&host, "main").await.unwrap();
let projects = &model.maven.unwrap().projects;
assert_eq!(projects.iter().filter(|p| p.artifact_id == "a").count(), 1);
let b = projects.iter().find(|p| p.artifact_id == "b").unwrap();
assert!(!b.aggregated);
assert_eq!(b.pom, canonical);
assert_eq!(dunce::canonicalize(root.join(&b.dir)).unwrap(), sibling);
assert_eq!(host.project("main").unwrap().content_roots(), vec![sibling.clone()]);
assert_eq!(host.project("main").unwrap().lsp.jdtls_import_roots(), vec![sibling.clone()]);
let reloaded = ops::build::build_model(&host, "main").await.unwrap();
assert_eq!(
reloaded.maven.unwrap().projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>(),
projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>()
);
assert_eq!(saved(), vec![canonical]);
ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", "[]")
.unwrap();
let gone = ops::build::build_model(&host, "main").await.unwrap();
assert!(gone.maven.unwrap().projects.iter().all(|p| p.artifact_id != "b"));
assert!(host.project("main").unwrap().content_roots().is_empty());
assert!(host.project("main").unwrap().lsp.jdtls_import_roots().is_empty());
assert_eq!(saved(), Vec::<String>::new());
assert_eq!(std::fs::read_to_string(root.join("pom.xml")).unwrap(), a_pom);
assert_eq!(std::fs::read_to_string(sibling.join("pom.xml")).unwrap(), b_pom);
host.shutdown().await;
}
+6
Переглянути файл
@@ -126,6 +126,12 @@ async fn serves_projects_files_documents_search_and_runs() {
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
assert_eq!(current["name"], "demo");
// The built-in web server runs where the project is; the client forwards its port.
let root = std::path::PathBuf::from(opened["root"].as_str().unwrap());
let url = invoke(&peer, "web_url", json!({ "path": root.join("src/main.py") })).await.unwrap();
let url = url.as_str().unwrap();
assert!(url.starts_with("http://127.0.0.1:") && url.ends_with("/demo/src/main.py"), "{url}");
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
+50
Переглянути файл
@@ -105,6 +105,56 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn python_effective_interpreter_falls_back_to_the_primary_project() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let primary = base.join("primary");
let venv_python =
if cfg!(windows) { primary.join(".venv/Scripts/python.exe") } else { primary.join(".venv/bin/python") };
std::fs::create_dir_all(venv_python.parent().unwrap()).unwrap();
std::fs::write(primary.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap();
std::fs::write(&venv_python, "").unwrap();
let member = base.join("attached");
std::fs::create_dir_all(member.join("src")).unwrap();
// The primary project with an attached folder (the "attach" flow, not a container workspace).
Workspace::attach(&primary, "Primary", vec![primary.clone(), member.clone()]).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&primary, true, false).unwrap();
host.trust.decide(&member, true, false).unwrap();
host.open_project("main", &primary).unwrap();
let python = |near: Option<&std::path::Path>| {
ops::sdk::sdk_python_effective(&host, "main", near.map(|p| p.to_path_buf())).unwrap().map(|p| p.into_path_buf())
};
// The primary project's own virtualenv, and the bare member inheriting it.
assert_eq!(python(Some(&primary)), Some(venv_python.clone()));
assert_eq!(python(Some(&member.join("src"))), Some(venv_python.clone()));
// The member's own settings win: an explicit interpreter configured for it
// (its file is created here — a missing one would be skipped as invalid).
let configured = member.join("selected").join(if cfg!(windows) { "python.exe" } else { "python" });
std::fs::create_dir_all(configured.parent().unwrap()).unwrap();
std::fs::write(&configured, "").unwrap();
ide_workspace::SettingsStore::load(&base.join("config"), Some(&member))
.set(
ide_workspace::SettingsScope::Project,
"languages.python.interpreter",
serde_json::to_value(configured.to_string_lossy().into_owned()).unwrap(),
)
.unwrap();
assert_eq!(python(Some(&member.join("src"))), Some(configured));
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
+1
Переглянути файл
@@ -55,6 +55,7 @@ tree-sitter-xml = "0.7.0"
tree-sitter-haskell = "0.24.1"
tree-sitter-dart = "0.2.0"
tree-sitter-graphql = "0.3.0"
tree-sitter-prisma-io = "1.6.0"
tree-sitter-gdscript = "6.1.0"
[dev-dependencies]
+40
Переглянути файл
@@ -0,0 +1,40 @@
; Prisma schema highlighting (our captures, not the grammar crate's). Later patterns win.
(identifier) @variable
(developer_comment) @comment
(comment) @comment.doc
(number) @number
(string) @string
[(true) (false) (null)] @keyword
(variable) @property
(column_declaration . (identifier) @property)
(type_expression . (identifier) @variable.parameter)
(enumeral (identifier) @constant)
(column_type (identifier) @type)
(column_type (call_expression (identifier) @type))
(call_expression (identifier) @function)
(model_declaration (identifier) @type)
(view_declaration (identifier) @type)
(type_declaration (identifier) @type)
(type_declaration (type_declaration_type) @type)
(enum_declaration (identifier) @type)
(datasource_declaration (identifier) @type)
(generator_declaration (identifier) @type)
(attribute (identifier) @attribute)
(attribute (call_expression (identifier) @attribute))
(attribute (member_expression (identifier) @attribute (property_identifier) @attribute))
(attribute (call_expression (member_expression (identifier) @attribute (property_identifier) @attribute)))
(block_attribute_declaration (identifier) @attribute)
(block_attribute_declaration (call_expression (identifier) @attribute))
["@" "@@"] @attribute
["datasource" "enum" "generator" "model" "type" "view"] @keyword
["(" ")" "[" "]" "{" "}"] @punctuation.bracket
["," ":" "."] @punctuation.delimiter
(maybe) @operator
"=" @operator
+8
Переглянути файл
@@ -0,0 +1,8 @@
(model_declaration (identifier) @name) @def.class
(view_declaration (identifier) @name) @def.class
(type_declaration . (identifier) @name (statement_block)) @def.struct
(enum_declaration (identifier) @name) @def.enum
(enumeral (identifier) @name) @def.enum_member
(datasource_declaration (identifier) @name) @outline.module
(generator_declaration (identifier) @name) @outline.module
(column_declaration . (identifier) @name) @def.field
+2
Переглянути файл
@@ -67,6 +67,7 @@ const BUILTIN: &[(&str, &str)] = &[
("php", include_str!("../../../languages/php.toml")),
("plaintext", include_str!("../../../languages/plaintext.toml")),
("powershell", include_str!("../../../languages/powershell.toml")),
("prisma", include_str!("../../../languages/prisma.toml")),
("properties", include_str!("../../../languages/properties.toml")),
("proto", include_str!("../../../languages/proto.toml")),
("python", include_str!("../../../languages/python.toml")),
@@ -1026,6 +1027,7 @@ mod tests {
("CMakeLists.txt", "cmake"),
("api.proto", "proto"),
("schema.graphql", "graphql"),
("schema.prisma", "prisma"),
("main.tf", "terraform"),
("prod.tfvars", "terraform-vars"),
("job.nomad", "hcl"),
+10
Переглянути файл
@@ -430,6 +430,16 @@ pub(crate) static SPECS: &[Spec] = &[
imports: NONE,
constructors: NONE,
},
Spec {
name: "prisma",
language: tree_sitter_prisma_io::LANGUAGE,
outline: outline!("prisma"),
highlights: Some(Highlights { query: highlights!("prisma"), first_wins: false }),
fold_nodes: NONE,
fold_bodies: NONE,
imports: NONE,
constructors: NONE,
},
Spec {
name: "make",
language: tree_sitter_make::LANGUAGE,
+22
Переглянути файл
@@ -501,6 +501,28 @@ mod tests {
("NEWHOPE", K::Constant),
],
),
(
"prisma",
"// hi\n/// Doc\nmodel User {\n id Int @id @default(autoincrement())\n email String? @db.VarChar(255)\n role Role\n @@index([email])\n}\nenum Role { ADMIN }\ndatasource db {\n provider = \"postgresql\"\n}\n",
&[
("// hi", K::Comment),
("/// Doc", K::DocComment),
("model", K::Keyword),
("User", K::Type),
("id", K::Property),
("Int", K::Type),
("@id", K::Attribute),
("@default", K::Attribute),
("autoincrement", K::FunctionCall),
("VarChar", K::Attribute),
("255", K::Number),
("Role", K::Type),
("@@index", K::Attribute),
("ADMIN", K::Constant),
("provider", K::Property),
("\"postgresql\"", K::String),
],
),
(
"hcl",
"# hi\nresource \"aws_instance\" \"web\" {\n ami = var.ami\n count = 2\n tags = { Name = \"web-${var.env}\" }\n name = upper(\"x\")\n}\n",
+11
Переглянути файл
@@ -886,6 +886,17 @@ fn outline_more_languages() {
Function GetUser
",
);
check_outline(
"prisma",
"datasource db {\n provider = \"sqlite\"\n}\nmodel User {\n id Int @id\n @@map(\"users\")\n}\nenum Role {\n ADMIN\n}\n",
"
Module db
Class User
Field id
Enum Role
EnumMember ADMIN
",
);
check_outline(
"nix",
"{ pkgs, ... }:\nlet\n foo = 1;\n bar = x: x;\nin { inherit foo; }\n",
+3
Переглянути файл
@@ -430,6 +430,9 @@ impl Handler for ClientHandler {
crate::kotlin_lsp::record_import_state(&self.server, self.generation, &params)
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
if self.server.running_gen(self.generation).is_some() {
self.server.set_java_ready();
}
mgr.java_import_ready(self.server.clone(), self.generation);
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ProjectStatus" => {
+58 -8
Переглянути файл
@@ -201,6 +201,10 @@ pub struct LspManager {
me: Weak<LspManager>,
project_root: PathBuf,
content_roots: RwLock<Vec<PathBuf>>,
/// Maven projects linked from outside `project_root`. jdtls scans them as extra
/// workspace folders; `projectConfigurations` stays unset so the opened root is
/// still discovered automatically.
jdtls_import_roots: RwLock<Vec<PathBuf>>,
languages: Arc<LanguageRegistry>,
sink: SharedSink,
launcher: Arc<dyn Launcher>,
@@ -444,6 +448,7 @@ impl LspManager {
me: me.clone(),
project_root,
content_roots: RwLock::new(Vec::new()),
jdtls_import_roots: RwLock::new(Vec::new()),
languages,
sink: {
let me = me.clone();
@@ -503,6 +508,28 @@ impl LspManager {
}
}
/// Directories of Maven projects linked from outside the opened root. The jdtls
/// rooted at the opened project scans them together with that root. An empty list
/// leaves discovery as the single `rootUri` scan.
pub fn set_jdtls_import_roots(&self, mut roots: Vec<PathBuf>) {
roots.retain(|root| root.is_absolute() && root.is_dir() && !root.starts_with(&self.project_root));
roots.sort();
roots.dedup();
let changed = {
let mut current = self.jdtls_import_roots.write();
let changed = *current != roots;
*current = roots;
changed
};
if changed && let Some(me) = self.me.upgrade() {
me.reconfigure_soon();
}
}
pub fn jdtls_import_roots(&self) -> Vec<PathBuf> {
self.jdtls_import_roots.read().clone()
}
/// Shares JDT LS within this manager; callers reconfigure after the hook's value changes.
pub fn set_jdtls_shared_hook(&self, hook: Box<JdtlsSharedFn>) {
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
@@ -537,10 +564,29 @@ impl LspManager {
if state.shared {
return state.roots.clone();
}
// The opened project's server also scans linked external Maven projects.
// Other in-root builds keep their own server and their own root.
if server.root == self.project_root {
return self.project_and_import_roots();
}
}
vec![server.root.clone()]
}
fn project_and_import_roots(&self) -> Vec<PathBuf> {
let mut roots = vec![self.project_root.clone()];
for root in self.jdtls_import_roots.read().iter() {
if root.is_absolute() && root.is_dir() && !roots.contains(root) {
roots.push(root.clone());
}
}
roots
}
fn imports_external_java(&self, config: &LanguageServerConfig, path: &Path) -> bool {
is_local_jdtls(config) && self.jdtls_import_roots.read().iter().any(|root| path.starts_with(root))
}
fn project_root_for(&self, path: &Path) -> PathBuf {
self.content_roots
.read()
@@ -557,7 +603,9 @@ impl LspManager {
let mut servers = self.servers.lock();
let project = self.project_root_for(path);
let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten();
let root = if shared.as_ref().is_some_and(|state| state.shared) {
let root = if shared.as_ref().is_some_and(|state| state.shared) || self.imports_external_java(cfg, path) {
// One server for the opened root and a linked external Maven project, so
// Java completion and navigation see the import jdtls made of both.
self.project_root.clone()
} else {
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
@@ -953,13 +1001,15 @@ impl LspManager {
}
init_options["settings"] = handler::all_settings(&cfg);
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
if shared {
init_options["workspaceFolders"] = json!(
self.server_workspace_roots(server)
.iter()
.map(|root| uri::path_to_uri_string(root))
.collect::<Vec<_>>()
);
// jdtls imports `initializationOptions.workspaceFolders` by scanning each
// directory. It ignores the LSP workspace folder list for that first import,
// and a `projectConfigurations` list replaces the scan. Extra Maven projects
// are therefore folders here, not an explicit build-file list, so projects
// under the opened root are still discovered.
let folders: Vec<String> =
self.server_workspace_roots(server).iter().map(|root| uri::path_to_uri_string(root)).collect();
if shared || folders.len() > 1 {
init_options["workspaceFolders"] = json!(folders);
}
let triggers: Vec<_> = self
.docs
+4 -4
Переглянути файл
@@ -80,7 +80,7 @@ pub(crate) struct Widened<R> {
impl<R> Widened<R> {
/// An answer from the server's analysis scope alone; `empty`: it found nothing.
pub fn only(first: R, empty: bool, server: &Server) -> Self {
let incomplete = (empty && server.is_busy()).then_some(ReferencesIncomplete::Indexing);
let incomplete = (empty && server.indexing()).then_some(ReferencesIncomplete::Indexing);
Self { first, second: None, incomplete }
}
}
@@ -130,7 +130,7 @@ impl LspManager {
F: Fn() -> Fut,
Fut: Future<Output = IdeResult<R>>,
{
let busy = t.server.is_busy();
let indexing = t.server.indexing();
// gopls searches the view of the file asked about; files opened for it only get views of
// their own (see `go_scope`).
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
@@ -143,7 +143,7 @@ impl LspManager {
};
let (first, candidates) = futures::join!(query(), candidates);
let mut first = first?;
if busy && covered(&first).is_empty() && t.server.wait_idle(INDEX_WAIT, cancel).await {
if indexing && covered(&first).is_empty() && t.server.wait_indexed(INDEX_WAIT, cancel).await {
first = query().await?;
}
if cancel.is_cancelled() {
@@ -151,7 +151,7 @@ impl LspManager {
}
// Servers report analysis progress often; only "nothing found" while it lasts is suspect.
let mut incomplete =
(t.server.is_busy() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
(t.server.indexing() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
let Some(candidates) = candidates else { return Ok(Widened { first, second: None, incomplete }) };
if candidates.limit_hit {
incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles));
+28 -8
Переглянути файл
@@ -127,7 +127,7 @@ pub struct Server {
published: Mutex<HashMap<PathBuf, Instant>>,
/// Woken on every `publishDiagnostics` (Inspect Code waits on it).
pub published_notify: Notify,
/// Woken when the last `$/progress` task ends.
/// Woken when an empty answer stops being suspect (see [`Server::indexing`]).
idle_notify: Notify,
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
/// one, then whether it is still loading the project.
@@ -148,6 +148,8 @@ pub struct Server {
/// The instance whose just registered external project is loading, until an open file's
/// diagnostics arrive (0: none).
pub external_loading: std::sync::atomic::AtomicU64,
/// jdtls sent `ServiceReady` on this instance: the project is imported.
java_ready: std::sync::atomic::AtomicBool,
/// kotlin-lsp's project import as the current instance last reported it.
pub(crate) import_state: watch::Sender<Option<crate::kotlin_lsp::ImportState>>,
}
@@ -195,6 +197,7 @@ impl Server {
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
external_loading: std::sync::atomic::AtomicU64::new(0),
java_ready: std::sync::atomic::AtomicBool::new(false),
import_state: watch::channel(None).0,
}
}
@@ -596,6 +599,22 @@ impl Server {
self.phase_ended();
}
/// Whether an empty search answer may only mean the server has not indexed the project
/// yet. jdtls' searches wait for its index themselves, and the progress it reports once
/// the project is imported is background work (builds, validation): only the import counts.
pub fn indexing(&self) -> bool {
if self.name == "jdtls" {
return self.is_busy() && !self.java_ready.load(std::sync::atomic::Ordering::Acquire);
}
self.is_busy()
}
/// Records jdtls' `ServiceReady`.
pub fn set_java_ready(&self) {
self.java_ready.store(true, std::sync::atomic::Ordering::Release);
self.phase_ended();
}
/// Whether progress token `token` is still running on this instance.
pub fn has_progress(&self, token: &str) -> bool {
self.progress.lock().contains_key(token)
@@ -613,7 +632,7 @@ impl Server {
fn phase_ended(&self) {
self.phase_notify.notify_waiters();
if !self.is_busy() {
if !self.indexing() {
self.idle_notify.notify_waiters();
}
}
@@ -623,17 +642,17 @@ impl Server {
self.phase_notify.notified()
}
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
/// Waits until [`Server::indexing`] ends; false on timeout or cancellation.
pub async fn wait_indexed(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.idle_notify.notified();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
if !self.indexing() || self.running().is_none() {
return !self.indexing();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = tokio::time::sleep_until(deadline) => return !self.indexing(),
() = cancel.cancelled() => return false,
}
}
@@ -723,6 +742,7 @@ impl Server {
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
*self.loading.lock() = None;
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
self.java_ready.store(false, std::sync::atomic::Ordering::Release);
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
}
@@ -957,7 +977,7 @@ mod tests {
stalled: true,
},
);
assert!(!s.is_busy(), "a stuck task must not hold wait_idle or ReferencesIncomplete::Indexing");
assert!(!s.is_busy(), "a stuck task must not hold wait_indexed or ReferencesIncomplete::Indexing");
s.progress.lock().get_mut("p").unwrap().stalled = false;
assert!(s.is_busy(), "a reporting task keeps the server busy");
}
+67
Переглянути файл
@@ -759,6 +759,40 @@ async fn java_classpath_does_not_retry_permanently_unavailable_servers() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn external_maven_import_joins_the_opened_root_without_replacing_discovery() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let external = tempfile::tempdir().unwrap();
let external = dunce::canonicalize(external.path()).unwrap();
std::fs::write(external.join("pom.xml"), "<project><artifactId>b</artifactId></project>").unwrap();
let outside = external.join("src/Main.java");
std::fs::create_dir_all(outside.parent().unwrap()).unwrap();
std::fs::write(&outside, "class External {}").unwrap();
let inside = h.root.join("src/App.java");
std::fs::create_dir_all(inside.parent().unwrap()).unwrap();
std::fs::write(&inside, "class App {}").unwrap();
// An in-root build with its own marker keeps a separate server.
let nested = h.root.join("nested");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("mock.toml"), "").unwrap();
let nested_file = nested.join("Nested.java");
std::fs::write(&nested_file, "class Nested {}").unwrap();
h.mgr.set_jdtls_import_roots(vec![external.clone()]);
h.mgr.did_open(DocId::next(), &inside, &h.lang, 1, "class App {}");
h.mgr.did_open(DocId::next(), &outside, &h.lang, 1, "class External {}");
h.mgr.did_open(DocId::next(), &nested_file, &h.lang, 1, "class Nested {}");
let state = h.launcher.state("jdtls");
eventually(|| state.requested("initialize").len() == 2).await;
let inits = state.requested("initialize");
let project = inits.iter().find(|init| init["rootUri"] == uri_of(&h.root)).unwrap();
assert_eq!(project["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)]));
assert!(project["initializationOptions"].get("projectConfigurations").is_none());
assert!(state.requested("initialize").iter().any(|init| init["rootUri"] == uri_of(&nested)));
assert_eq!(h.mgr.servers().len(), 2);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn shared_java_external_roots_reach_initialization_queries_and_watches() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
@@ -4925,6 +4959,39 @@ async fn scoped_java_usages_search_the_sources_unless_libraries_are_asked_for()
assert!(seen.lock().is_empty());
}
/// jdtls' searches wait for its index themselves, and it reports background work (builds,
/// validation) for long stretches: a method nothing calls is answered at once meanwhile.
#[tokio::test(flavor = "multi_thread")]
async fn java_usages_of_an_unused_method_do_not_wait_for_background_work() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
s.respond("workspace/executeCommand", json!([]));
let (doc, _) = h.open("a.java", "void unused() {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
s.peer()
.notify("$/progress", json!({ "token": "build", "value": { "kind": "begin", "title": "Building" } }))
.unwrap();
h.wait_status("jdtls", ServerStatus::Busy).await;
// Until the project is imported, finding nothing may only mean it is not indexed yet.
let refs = h.mgr.references(doc, Pos::new(0, 6), true, false, Default::default()).await.unwrap();
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
s.peer().notify("language/status", json!({"type": "ServiceReady", "message": "ServiceReady"})).unwrap();
for _ in 0..200 {
let refs = h.mgr.references(doc, Pos::new(0, 6), true, false, Default::default()).await.unwrap();
if refs.incomplete.is_none() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let started = std::time::Instant::now();
let refs = h.mgr.references(doc, Pos::new(0, 6), true, true, Default::default()).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(2), "waited {:?}", started.elapsed());
assert!(refs.locations.is_empty());
assert_eq!(refs.incomplete, None);
}
/// A library method's usages in the sources are shown before the dependencies and the JDK
/// are searched, which takes seconds; scope widening searches the sources alone again.
#[tokio::test(flavor = "multi_thread")]
+1
Переглянути файл
@@ -14,6 +14,7 @@ serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tokio.workspace = true
futures.workspace = true
tracing.workspace = true
parking_lot.workspace = true
globset.workspace = true
+26 -8
Переглянути файл
@@ -10,6 +10,9 @@ use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Remote folders listed (or files checked) at the same time.
pub(crate) const LIST_CONCURRENCY: usize = 16;
/// Mapping eligibility uses a common synthetic server root: mapping and exclusion
/// paths are both relative to that root, so checking never opens a connection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -199,20 +202,35 @@ impl Deployment<'_> {
}
/// Remote files under `dir` (recursively), excluded ones left out.
/// Files under `dir`, by path. Lists [`LIST_CONCURRENCY`] folders at a time: one round trip
/// per folder after another made large trees take minutes.
async fn remote_files(&self, dir: &str, out: &mut Vec<ide_ssh::RemoteEntry>) -> IdeResult<()> {
for e in self.fs.list(dir).await? {
use futures::stream::{FuturesUnordered, StreamExt};
let start = out.len();
let mut pending = vec![dir.to_owned()];
let mut listing = FuturesUnordered::new();
loop {
while listing.len() < LIST_CONCURRENCY
&& let Some(dir) = pending.pop()
{
listing.push(async move { self.fs.list(&dir).await });
}
let Some(entries) = listing.next().await else { break };
if self.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if self.exclude.remote(&e.path) {
continue;
}
if e.is_dir && !e.is_symlink {
Box::pin(self.remote_files(&e.path, out)).await?;
} else if !e.is_dir {
out.push(e);
for e in entries? {
if self.exclude.remote(&e.path) {
continue;
}
if e.is_dir && !e.is_symlink {
pending.push(e.path);
} else if !e.is_dir {
out.push(e);
}
}
}
out[start..].sort_by(|a, b| a.path.cmp(&b.path));
Ok(())
}
+32 -12
Переглянути файл
@@ -470,13 +470,27 @@ impl RemoteService {
preserve_time: true,
cancel: CancellationToken::new(),
};
let started = std::time::Instant::now();
out(OutputChunk::System { text: format!("Comparing project files with {remote_root}...\n") });
let mut progress = |_: &str, _: usize, _: usize| {};
let report = d.push_changes(&root, &mut progress).await;
let synced = sync_external_libraries(&fs, &libraries, preserve_time).await;
if report.is_ok() && !libraries.is_empty() {
out(OutputChunk::System {
text: format!("Checking {} external classpath entries...\n", libraries.len()),
});
}
let synced = match &report {
Ok(_) => sync_external_libraries(&fs, &libraries, preserve_time).await,
Err(_) => Ok(0),
};
fs.close().await;
let report = report?;
out(OutputChunk::System {
text: format!("Uploaded {} changed file(s) to {remote_root}\n", report.files),
text: format!(
"Uploaded {} changed file(s) to {remote_root} in {:.1}s\n",
report.files,
started.elapsed().as_secs_f64()
),
});
match report.errors.first() {
Some(e) => Err(IdeError::new(
@@ -517,9 +531,8 @@ async fn sync_external_libraries(
fn fail(path: &str, e: IdeError) -> IdeError {
IdeError::new(ide_core::error::ErrorKind::Io, format!("sync of {path} failed: {}", e.message))
}
let mut synced = 0;
let mut files = Vec::new();
for (local, remote_base) in libraries {
let mut files = Vec::new();
if local.is_dir() {
let walk = ignore::WalkBuilder::new(local).standard_filters(false).follow_links(false).build();
for entry in walk.flatten() {
@@ -534,7 +547,11 @@ async fn sync_external_libraries(
} else {
files.push((local.clone(), remote_base.clone()));
}
for (file, remote) in files {
}
// The remote copies are checked several at a time (one round trip each); uploads follow.
use futures::stream::{self, StreamExt, TryStreamExt};
let stale: Vec<(PathBuf, String)> = stream::iter(files)
.map(|(file, remote)| async move {
let size = std::fs::metadata(&file).map_err(|e| fail(&remote, e.into()))?.len();
let local_stamp = FileStamp { size, mtime: mtime_of(&file) };
let remote_stamp = match fs.stat(&remote).await.map_err(|e| fail(&remote, e))? {
@@ -544,14 +561,17 @@ async fn sync_external_libraries(
Some(e) => Some(FileStamp { size: e.size, mtime: e.mtime }),
None => None,
};
if matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same) {
continue;
}
fs.upload(&file, &remote, preserve_time).await.map_err(|e| fail(&remote, e))?;
synced += 1;
}
let same = matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same);
Ok((!same).then_some((file, remote)))
})
.buffered(crate::deploy::LIST_CONCURRENCY)
.try_filter_map(|x| async move { Ok(x) })
.try_collect()
.await?;
for (file, remote) in &stale {
fs.upload(file, remote, preserve_time).await.map_err(|e| fail(remote, e))?;
}
Ok(synced)
Ok(stale.len())
}
fn sanitize(s: &str) -> String {
+14 -2
Переглянути файл
@@ -263,10 +263,15 @@ fn target_command(
RemoteCommand { program, args, cwd, env, shell_line: None }
}
/// How long Stop waits for the channel to close after `KILL`.
const KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
struct RemoteRun {
info: Mutex<RunInfo>,
process: Mutex<Option<Arc<RemoteProcess>>>,
cancel: CancellationToken,
/// Stop gave up waiting for the channel to report the exit: the run finishes locally.
abandon: CancellationToken,
stopping: AtomicBool,
/// The runtime it started on: stdin / stop requests may come from threads without one.
runtime: tokio::runtime::Handle,
@@ -318,6 +323,7 @@ impl RemoteRuns {
info: Mutex::new(info.clone()),
process: Mutex::new(None),
cancel: CancellationToken::new(),
abandon: CancellationToken::new(),
stopping: AtomicBool::new(false),
runtime: tokio::runtime::Handle::current(),
});
@@ -361,7 +367,8 @@ impl RemoteRuns {
Ok(())
}
/// `INT`, then `KILL` (closing the channel) after `grace`.
/// `INT`, then `KILL` (closing the channel) after `grace`; a channel that still reports no
/// exit after another [`KILL_WAIT`] is left behind and the run finishes as stopped.
pub fn stop(&self, id: RunId, grace: std::time::Duration) -> IdeResult<()> {
let run =
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?;
@@ -374,6 +381,8 @@ impl RemoteRuns {
tokio::time::sleep(grace).await;
if run.info.lock().running {
let _ = process.signal(Sig::KILL).await;
tokio::time::sleep(KILL_WAIT).await;
run.abandon.cancel();
}
});
Ok(())
@@ -420,7 +429,10 @@ async fn run_remote(
.await?;
let process = Arc::new(process);
*run.process.lock() = Some(process.clone());
Ok(process.wait().await)
tokio::select! {
code = process.wait() => Ok(code),
_ = run.abandon.cancelled() => Ok(None),
}
}
#[cfg(test)]
+36 -7
Переглянути файл
@@ -66,16 +66,19 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<Pat
/// The project interpreter: the configured one, else a project virtualenv
/// (`.venv`, `venv`, …) or pixi environment (`.pixi/envs/<env>`) in the project
/// root, else the Python the project pins for mise / asdf.
/// root, else the Python the project pins for mise / asdf. A configured
/// interpreter whose executable is gone (uninstalled, moved) is skipped like
/// PyCharm's "Invalid" SDKs, so runs fall back instead of failing on a dead path.
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
setting_path(root, &s.languages.python.interpreter)
.filter(|p| p.is_file())
.or_else(|| project_venv_python(root))
.or_else(|| pinned::pinned_python(root))
}
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
if let Some(python) = setting_path(root, &s.languages.python.interpreter) {
if let Some(python) = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file()) {
return Some(python);
}
let near = near.filter(|dir| dir.starts_with(root)).unwrap_or(root);
@@ -1446,7 +1449,22 @@ pub fn apply_maven_env(c: &mut RunConfig, root: &Path, s: &Settings) {
/// cargo / go configs the selected toolchain, Maven / Gradle their runner JDK
/// (and Maven the configured home and options).
pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) {
let explicit_python = setting_path(root, &s.languages.python.interpreter);
apply_to_run_configs_in(configs, root, None, s)
}
/// [`apply_to_run_configs`] with an inherited fallback project: a workspace member
/// (`root`) with no Python of its own (no interpreter setting, virtualenv or pin)
/// runs on the primary project's (`fallback`) — the module SDK a member without its
/// own inherits in IDEA. `None` keeps the configuration's own project.
pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback: Option<&Path>, s: &Settings) {
let inherited = fallback.filter(|f| *f != root);
// An interpreter whose executable is gone is skipped (like PyCharm's "Invalid"
// SDKs): the run configuration uses the venv / fallback instead of a dead path.
let explicit_python = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file());
let inherited_python = inherited
.and_then(|f| setting_path(f, &s.languages.python.interpreter))
.filter(|p| p.is_file())
.filter(|_| explicit_python.is_none());
let pm = non_empty(&s.languages.node.package_manager);
let php = setting_path(root, &s.languages.php.interpreter);
let rust = rust_bin(root, s);
@@ -1464,7 +1482,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
// A module outside the project (an external linked build) keeps the project's choices.
let near = if owner.starts_with(root) { owner } else { root };
match tool {
"python" => python_interpreter_near(root, Some(near), s),
// A member with no Python of its own runs on the primary project's.
"python" => python_interpreter_near(root, Some(near), s)
.or_else(|| inherited.and_then(|f| python_interpreter_near(f, Some(f), s))),
"node" if non_empty(&s.languages.node.interpreter).is_none() => {
node_dir(near, s).or_else(|| node_dir(root, s))
}
@@ -1496,7 +1516,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
let mut path_dirs: Vec<PathBuf> = Vec::new();
match c.kind.as_str() {
"python" | "pytest" | "django-test" => {
if let Some(py) = &explicit_python {
let python = toolchain("python", &owner);
// The member's explicit interpreter, else the inherited one, else the
// member's own venv / pin.
if let Some(py) = explicit_python.as_ref().or(inherited_python.as_ref()).or(python.as_ref()) {
let runner = PY_RUNNERS.contains(&c.command.as_str());
if (c.kind == "python" || c.kind == "django-test") && (runner || is_python_command(&c.command)) {
if runner && c.args.first().map(String::as_str) == Some("run") {
@@ -1515,7 +1538,6 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
c.args = ["-m".to_owned(), "pytest".to_owned()].into_iter().chain(rest).collect();
}
}
let python = toolchain("python", &owner);
if let Some(venv) = python.as_deref().and_then(venv_of)
&& !c.env.contains_key("VIRTUAL_ENV")
{
@@ -1653,9 +1675,16 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
/// `VIRTUAL_ENV`, `JAVA_HOME`, `GOROOT`, …) or an absolute command its bare name
/// resolves to (the project interpreter, a toolchain's `cargo` / `go`, Maven's `mvn`).
pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConfig {
without_derived_in(config, root, None, s)
}
/// [`without_derived`] with the inherited fallback project the configuration runs with
/// (see [`apply_to_run_configs_in`]): a derived command or environment entry is derived
/// only when re-applying settings with the same fallback reproduces it.
pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Path>, s: &Settings) -> RunConfig {
let applied = |c: &RunConfig| {
let mut c = c.clone();
apply_to_run_configs(std::slice::from_mut(&mut c), root, s);
apply_to_run_configs_in(std::slice::from_mut(&mut c), root, fallback, s);
c
};
let mut out = config.clone();
+121 -15
Переглянути файл
@@ -1,6 +1,7 @@
use crate::apply::{
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
ruff_configuration, terminal_activation, tool_install_environment, tsdk, without_derived,
apply_to_run_configs, apply_to_run_configs_in, configure_server, jdtls_jdk, maven_command, maven_options,
project_jdk, python_interpreter, ruff_configuration, terminal_activation, tool_install_environment, tsdk,
without_derived, without_derived_in,
};
use crate::python::parse_pip_list;
use crate::{bin_dir, exe, venv_of};
@@ -62,6 +63,14 @@ fn run(kind: &str, command: &str, args: &[&str]) -> RunConfig {
}
}
/// An interpreter executable at `rel` under `root` (resolution skips paths that don't exist).
fn fake_interpreter(root: &Path, rel: &str) -> std::path::PathBuf {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "").unwrap();
path
}
#[test]
fn project_venv_is_the_default_interpreter() {
if cfg!(windows) {
@@ -74,8 +83,10 @@ fn project_venv_is_the_default_interpreter() {
assert_eq!(python_interpreter(tmp.path(), &s), Some(py.clone()));
assert_eq!(venv_of(&py), Some(tmp.path().join(".venv")));
let mut s = Settings::default();
s.languages.python.interpreter = "/opt/py/bin/python3".into();
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
// A configured interpreter wins over the virtualenv while its executable exists.
let configured = fake_interpreter(tmp.path(), "opt/py/bin/python3");
s.languages.python.interpreter = configured.to_string_lossy().into_owned();
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), configured);
}
#[test]
@@ -304,7 +315,9 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
}
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
s.languages.python.interpreter = "/opt/py/bin/python3".into();
let python = fake_interpreter(tmp.path(), "opt/py/bin/python3");
let python = python.to_string_lossy().into_owned();
s.languages.python.interpreter = python.clone();
s.languages.node.package_manager = "pnpm".into();
let mut configs = vec![
run("python", "uv", &["run", "python", "-m", "app"]),
@@ -316,17 +329,17 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
run("django-test", ".venv/bin/python", &["manage.py", "test", "polls"]),
];
apply_to_run_configs(&mut configs, tmp.path(), &s);
assert_eq!(configs[0].command, "/opt/py/bin/python3");
assert_eq!(configs[0].command, python);
assert_eq!(configs[0].args, ["-m", "app"]);
assert_eq!(configs[1].command, "/opt/py/bin/python3");
assert_eq!(configs[1].command, python);
assert_eq!(configs[1].args, ["-m", "pytest"]);
assert_eq!(configs[2].args, ["-m", "pytest", "-x"]);
assert_eq!(configs[3].command, "pnpm");
assert_eq!(configs[4].command, "python3", "shell configs are left alone");
// Django tests run on the selected interpreter, like `manage.py runserver`.
assert_eq!(configs[5].command, "/opt/py/bin/python3");
assert_eq!(configs[5].command, python);
assert_eq!(configs[5].args, ["manage.py", "test"]);
assert_eq!(configs[6].command, "/opt/py/bin/python3");
assert_eq!(configs[6].command, python);
assert_eq!(configs[6].args, ["manage.py", "test", "polls"]);
}
@@ -2231,11 +2244,13 @@ fn nested_python_configs_get_their_own_virtualenv_but_explicit_settings_stay_pro
apply_to_run_configs(std::slice::from_mut(&mut member), root, &Settings::default());
assert_eq!(member.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
let mut settings = Settings::default();
settings.languages.python.interpreter = "custom/bin/python".into();
settings.languages.python.interpreter = root.join("custom/bin").join(exe("python")).to_string_lossy().into_owned();
std::fs::create_dir_all(root.join("custom/bin")).unwrap();
std::fs::write(root.join("custom/bin").join(exe("python")), "").unwrap();
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("apps/python".into());
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
assert_eq!(config.command, root.join("custom/bin/python").to_string_lossy());
assert_eq!(config.command, root.join("custom/bin").join(exe("python")).to_string_lossy());
}
#[test]
@@ -2248,8 +2263,94 @@ fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() {
let mut settings = Settings::default();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(inner));
assert_eq!(crate::apply::python_interpreter_near(root, Some(&root.join("apps/other")), &settings), Some(outer));
settings.languages.python.interpreter = "selected/python".into();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python")));
let mut settings = Settings::default();
let selected = root.join("selected").join(exe("python"));
std::fs::create_dir_all(root.join("selected")).unwrap();
std::fs::write(&selected, "").unwrap();
settings.languages.python.interpreter = selected.to_string_lossy().into_owned();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(selected));
}
#[test]
fn an_interpreter_that_no_longer_exists_falls_back_to_the_project_virtualenv() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let venv = fake_venv(root, ".venv");
let mut settings = Settings::default();
settings.languages.python.interpreter = root.join("gone/python").to_string_lossy().into_owned();
// Resolution skips the missing executable like PyCharm's "Invalid" SDKs.
assert_eq!(crate::apply::python_interpreter(root, &settings), Some(venv.clone()));
assert_eq!(crate::apply::python_interpreter_near(root, None, &settings), Some(venv.clone()));
// Run configurations use the fallback instead of the dead path.
let mut config = run("python", "python3", &["main.py"]);
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
assert_eq!(config.command, venv.to_string_lossy());
assert_eq!(config.env["VIRTUAL_ENV"], venv.parent().unwrap().parent().unwrap().to_string_lossy());
// A configured interpreter that still exists keeps winning over the venv.
let real = root.join("real");
std::fs::create_dir_all(&real).unwrap();
let real_python = real.join(exe("python"));
std::fs::write(&real_python, "").unwrap();
settings.languages.python.interpreter = real_python.to_string_lossy().into_owned();
assert_eq!(crate::apply::python_interpreter(root, &settings), Some(real_python));
}
#[test]
fn a_member_without_python_falls_back_to_the_primary_project_interpreter() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let primary = root.join("primary");
let bare = root.join("bare");
let outer = fake_venv(&primary, ".venv");
// An attached member with a venv of its own uses it.
let own = fake_venv(&root.join("own"), ".venv");
let settings = Settings::default();
assert_eq!(crate::apply::python_interpreter_near(&root.join("own"), Some(&own), &settings), Some(own));
// A run configuration of the bare member runs on the primary project's venv.
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("src".into());
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &settings);
assert_eq!(config.command, outer.to_string_lossy());
assert_eq!(config.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
// Saving strips what settings derived, so the fallback keeps following settings.
let stored = without_derived_in(&config, &bare, Some(&primary), &settings);
assert_eq!(stored.command, exe("python"));
assert!(!stored.env.contains_key("VIRTUAL_ENV"));
// The member's explicit interpreter wins over the primary project's venv —
// when it exists (bare names stay bare; a missing path falls back).
let mut chosen = Settings::default();
chosen.languages.python.interpreter = root.join("bare/python").to_string_lossy().into_owned();
std::fs::create_dir_all(&bare).unwrap();
let bare_python = bare.join(exe("python"));
std::fs::write(&bare_python, "").unwrap();
chosen.languages.python.interpreter = bare_python.to_string_lossy().into_owned();
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("src".into());
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &chosen);
assert_eq!(config.command, bare_python.to_string_lossy());
assert!(!config.env.contains_key("VIRTUAL_ENV"));
// An interpreter inherited from the primary follows a later change of its setting
// (the paths are created here: resolution skips missing executables).
let mut inherited = Settings::default();
for dir in ["primary", "moved"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join(exe("python")), "").unwrap();
}
let primary_python = root.join("primary").join(exe("python"));
let moved_python = root.join("moved").join(exe("python"));
inherited.languages.python.interpreter = primary_python.to_string_lossy().into_owned();
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("src".into());
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &inherited);
assert_eq!(config.command, primary_python.to_string_lossy());
let stored = without_derived_in(&config, &bare, Some(&primary), &inherited);
inherited.languages.python.interpreter = moved_python.to_string_lossy().into_owned();
let mut reloaded = stored;
apply_to_run_configs_in(std::slice::from_mut(&mut reloaded), &bare, Some(&primary), &inherited);
assert_eq!(reloaded.command, moved_python.to_string_lossy());
}
#[test]
@@ -2350,12 +2451,17 @@ fn tool_installs_resolve_project_relative_sdks_and_leave_path_fallback_unconfigu
assert!(!defaults.env.contains_key("GOROOT"));
s.languages.go.goroot = "go-sdk".into();
s.languages.node.interpreter = "node-sdk/bin/node".into();
s.languages.python.interpreter = "python-sdk/bin/python3.14".into();
s.languages.ruby.interpreter = "ruby-sdk/bin/ruby".into();
let runtime = tool_install_environment(root, &s);
assert_eq!(runtime.commands["go"], root.join("go-sdk/bin").join(if cfg!(windows) { "go.exe" } else { "go" }));
assert_eq!(runtime.env["GOROOT"], root.join("go-sdk").to_string_lossy());
assert_eq!(runtime.commands["node"], root.join("node-sdk/bin/node"));
assert_eq!(runtime.commands["python"], root.join("python-sdk/bin/python3.14"));
assert_eq!(runtime.commands["ruby"], root.join("ruby-sdk/bin/ruby"));
// A configured interpreter whose executable is missing is skipped here too:
// the tool install environment only names Pythons that can actually run.
std::fs::create_dir_all(root.join("python-sdk/bin")).unwrap();
let real = root.join("python-sdk/bin").join(exe("python3.14"));
std::fs::write(&real, "").unwrap();
s.languages.python.interpreter = real.to_string_lossy().into_owned();
assert_eq!(tool_install_environment(root, &s).commands["python"], real);
}
+11 -1
Переглянути файл
@@ -230,7 +230,14 @@ impl Session {
let mut on_output = on_output;
tokio::spawn(async move {
let mut code = None;
while let Some(msg) = read.wait().await {
loop {
// After the exit status, output still in flight is read until the channel goes
// quiet: a child that inherited stdout keeps the channel open past the exit.
let msg = match code {
None => read.wait().await,
Some(_) => tokio::time::timeout(EXIT_DRAIN, read.wait()).await.ok().flatten(),
};
let Some(msg) = msg else { break };
match msg {
ChannelMsg::Data { data } => on_output(Output::Stdout(&data)),
ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)),
@@ -317,6 +324,9 @@ impl Session {
}
}
/// How long a process's channel may stay quiet after its exit status before it counts as finished.
const EXIT_DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
/// A chunk of a remote process's output.
pub enum Output<'a> {
Stdout(&'a [u8]),
+30 -3
Переглянути файл
@@ -59,7 +59,8 @@ impl server::Handler for Sshd {
Ok(())
}
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3.
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3. `linger` exits but keeps
/// the channel open, as when a child it started still holds stdout.
async fn exec_request(
&mut self,
channel: ChannelId,
@@ -70,8 +71,10 @@ impl server::Handler for Sshd {
session.data(channel, format!("ran: {}", String::from_utf8_lossy(data)).into_bytes())?;
session.extended_data(channel, 1, b"oops".to_vec())?;
session.exit_status_request(channel, 3)?;
session.eof(channel)?;
session.close(channel)?;
if data != b"linger" {
session.eof(channel)?;
session.close(channel)?;
}
Ok(())
}
@@ -294,6 +297,30 @@ async fn runs_commands_and_streams_processes() {
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
}
#[tokio::test]
async fn a_process_that_exits_finishes_even_while_its_channel_stays_open() {
let env = env().await;
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
let seen: Arc<Mutex<String>> = Arc::default();
let s = seen.clone();
let process = session
.spawn(
"linger",
None,
Box::new(move |o| {
if let Output::Stdout(b) = o {
s.lock().push_str(&String::from_utf8_lossy(b));
}
}),
)
.await
.unwrap();
let code = tokio::time::timeout(std::time::Duration::from_secs(10), process.wait())
.await
.expect("the exit status must end the process");
assert_eq!((code, seen.lock().as_str()), (Some(3), "ran: linger"));
}
#[tokio::test]
async fn upload_reports_a_command_that_stops_reading() {
let env = env().await;
+11
Переглянути файл
@@ -407,6 +407,17 @@ pub static TOOLS: &[ToolSpec] = &[
homepage: "https://github.com/graphql/graphiql",
needs: &[],
},
ToolSpec {
name: "prisma-language-server",
display: "Prisma Language Server",
kind: LanguageServer,
commands: &["prisma-language-server"],
method: Method::Npm { packages: &["@prisma/language-server"] },
fallback: None,
version: V,
homepage: "https://github.com/prisma/language-tools",
needs: &[],
},
ToolSpec {
name: "intelephense",
display: "Intelephense (PHP)",
+11
Переглянути файл
@@ -783,6 +783,17 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
Ok(self.blob_of(path, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
}
/// The file's git mode at `rev` as printed by git (`100644`, `100755`, `120000`…),
/// `None` when absent there. `rev` follows `file_at`'s rules, `WT` reads the worktree.
pub fn file_mode_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
if rev == Some("WT") {
return repo::worktree_mode(self.root(), &self.rel_path(path)?);
}
let rel = self.rel_path(path)?;
let repo = self.gix()?;
repo::mode_at(&repo, &rel, rev.unwrap_or("HEAD"))
}
/// Raw revision bytes, for encoding detection when the working file is absent.
pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<Vec<u8>>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
+50
Переглянути файл
@@ -255,6 +255,56 @@ fn index_spelling(index: &gix::index::State, rel: &str) -> Option<String> {
found
}
/// The mode a worktree file would be committed with (`100755` when executable,
/// `100644` otherwise, `120000` for a symlink), `None` when `abs` is not a file.
/// On filesystems without an executable bit git can't record one, so it is `100644`.
pub(crate) fn worktree_mode(root: &Path, rel: &str) -> IdeResult<Option<String>> {
let meta = match std::fs::symlink_metadata(root.join(rel)) {
Ok(m) => m,
Err(_) => return Ok(None),
};
if meta.file_type().is_symlink() {
return Ok(Some("120000".to_owned()));
}
if !meta.is_file() {
return Ok(None);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Ok(Some(if meta.permissions().mode() & 0o111 != 0 { "100755" } else { "100644" }.to_owned()))
}
#[cfg(not(unix))]
Ok(Some("100644".to_owned()))
}
/// The entry's mode of `rel` at `rev` as git prints it (`100644`, `100755`, `120000`,
/// `160000`…), `None` when the path is absent there. Same `rev` rules as [`blob_at`].
pub(crate) fn mode_at(repo: &gix::Repository, rel: &str, rev: &str) -> IdeResult<Option<String>> {
if let Some(stage) = rev.strip_prefix(':') {
let stage = match stage {
"" | "0" => gix::index::entry::Stage::Unconflicted,
"1" => gix::index::entry::Stage::Base,
"2" => gix::index::entry::Stage::Ours,
"3" => gix::index::entry::Stage::Theirs,
other => return Err(IdeError::invalid(format!("invalid index stage :{other}"))),
};
let index = repo.index_or_empty().map_err(gx)?;
let Some(entry) = index.entry_by_path_and_stage(BStr::new(rel.as_bytes()), stage) else {
return Ok(None);
};
return Ok(entry.mode.to_tree_entry_mode().map(|m| format!("{:o}", m.value())));
}
let Some(tree) = tree_of(repo, rev)? else { return Ok(None) };
let Some(entry) = tree.lookup_entry_by_path(Path::new(rel)).map_err(gx)? else {
return Ok(None);
};
if !entry.mode().is_blob_or_symlink() {
return Ok(None);
}
Ok(Some(format!("{:o}", entry.mode().value())))
}
/// Blob content of `rel` at `rev`. `rev` is a revision (`HEAD`, a branch, an id) or
/// `:N` for index stage N (`:` / `:0` = the merged index entry).
pub(crate) fn blob_at(repo: &gix::Repository, rel: &str, rev: &str) -> IdeResult<Option<Vec<u8>>> {
+36
Переглянути файл
@@ -125,6 +125,8 @@ fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
ChangeKind::Conflicted
}
EntryStatus::Change(WtChange::Removed) => ChangeKind::Deleted,
// `content_change` is `None` when only the executable bit changed:
// still a modification `git status` lists and `git commit` records.
EntryStatus::Change(_) => ChangeKind::Modified,
EntryStatus::IntentToAdd => ChangeKind::Added,
EntryStatus::NeedsUpdate(_) => continue,
@@ -402,6 +404,40 @@ mod tests {
assert_eq!(from_git.1, ["conflict.txt"]);
}
/// A chmod-only change is a modification `git status` lists and `git commit`
/// records, so status keeps it (#2853): hidden, the Commit view could not show
/// or commit what a push would carry. `core.fileMode=false` reports nothing.
#[cfg(unix)]
#[test]
fn a_mode_only_change_is_reported_only_when_git_tracks_modes() {
use std::os::unix::fs::PermissionsExt;
for file_mode in ["true", "false"] {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
git(dir, &["config", "user.name", "Test User"]);
git(dir, &["config", "user.email", "[email protected]"]);
git(dir, &["config", "commit.gpgsign", "false"]);
git(dir, &["config", "core.fileMode", file_mode]);
std::fs::write(dir.join("script.sh"), "#!/bin/sh\n").unwrap();
commit_all(dir, "base");
std::fs::set_permissions(dir.join("script.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
let repo = Repo::discover(dir).unwrap().expect("repository found");
let status = repo.status().unwrap();
let entry = status.iter().find(|f| f.rel_path == "script.sh");
if file_mode == "true" {
assert_eq!(
entry.and_then(|f| f.unstaged),
Some(ChangeKind::Modified),
"a mode-only change is modified"
);
} else {
assert!(entry.is_none(), "core.fileMode=false hides mode changes");
}
}
}
/// git answers status when gitoxide can't read a directory; in Restricted Mode that run
/// must not execute the repository's filters.
#[cfg(unix)]
+9 -3
Переглянути файл
@@ -235,7 +235,7 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
let head_only = req.method == "HEAD";
let response = if !matches!(req.method.as_str(), "GET" | "HEAD") {
Response::Error(405, "Method Not Allowed")
} else if !host_allowed(req.host.as_deref(), root.port) {
} else if !host_allowed(req.host.as_deref()) {
Response::Error(403, "Forbidden")
} else {
resolve(root, &req.target).await
@@ -274,8 +274,11 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
sock.shutdown().await
}
fn host_allowed(host: Option<&str>, port: u16) -> bool {
host.is_some_and(|h| h == format!("127.0.0.1:{port}") || h == format!("localhost:{port}"))
/// Loopback names only (DNS rebinding). Any port: a remote host's server is reached through
/// a forwarded port of the client, and a browser names the port it connected to anyway.
fn host_allowed(host: Option<&str>) -> bool {
host.and_then(|h| h.rsplit_once(':'))
.is_some_and(|(name, port)| matches!(name, "127.0.0.1" | "localhost") && port.parse::<u16>().is_ok())
}
async fn resolve(root: &Root, target: &str) -> Response {
@@ -424,6 +427,9 @@ mod tests {
let other = url.replacen(token, "0123", 1);
assert!(get(&other, None).await.starts_with("HTTP/1.1 404"));
assert!(get(&url, Some("evil.example:80")).await.starts_with("HTTP/1.1 403"));
assert!(get(&url, Some("127.0.0.1.evil.example:80")).await.starts_with("HTTP/1.1 403"));
// Through a forwarded port of a remote client.
assert!(get(&url, Some("127.0.0.1:61234")).await.starts_with("HTTP/1.1 200"));
}
#[cfg(unix)]
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ All keys are kebab-case. Only `id` and `name` are required; a definition with no
| `formatter` | table | External formatter; see below. |
| `scratch-template` | string | Content of a new scratch file of this language. `$END$` marks where the caret goes. |
Bundled tree-sitter grammars: `bash`, `c`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `elixir`, `gdscript`, `go`, `graphql`, `haskell`, `hcl`, `html`, `java`, `javascript`, `json`, `kotlin`, `lua`, `make`, `markdown`, `nix`, `ocaml`, `ocaml_interface`, `php`, `proto`, `python`, `ruby`, `rust`, `scala`, `swift`, `toml`, `tsx`, `typescript`, `xml`, `yaml`, `zig`. A new grammar has to be compiled into the IDE; a plugin can provide highlighting and structure for a language without a grammar instead (see [Language features](/en/plugins/guides/language-features)).
Bundled tree-sitter grammars: `bash`, `c`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `elixir`, `gdscript`, `go`, `graphql`, `haskell`, `hcl`, `html`, `java`, `javascript`, `json`, `kotlin`, `lua`, `make`, `markdown`, `nix`, `ocaml`, `ocaml_interface`, `php`, `prisma`, `proto`, `python`, `ruby`, `rust`, `scala`, `swift`, `toml`, `tsx`, `typescript`, `xml`, `yaml`, `zig`. A new grammar has to be compiled into the IDE; a plugin can provide highlighting and structure for a language without a grammar instead (see [Language features](/en/plugins/guides/language-features)).
### `[[framework-ui-modes]]`
+1
Переглянути файл
@@ -151,6 +151,7 @@ The table lists every built-in language definition. Server and debugger names in
| PHP | `php` | `.php`, `.phtml`, `.php3`, `.php4`, `.php5`, … | Yes | Yes | **intelephense** → phpactor + **laravel-lsp** | **php-debug** |
| Plain Text | `plaintext` | `.txt`, `.text` | — | — | — | — |
| PowerShell | `powershell` | `.ps1`, `.psm1`, `.psd1`, `.pssc`, `.psrc` | Yes | — | powershell-editor-services | — |
| Prisma | `prisma` | `.prisma` | Yes | Yes | **prisma-language-server** | — |
| Properties | `properties` | `.properties`, `.prefs` | Yes | — | — | — |
| Protocol Buffers | `proto` | `.proto` | Yes | Yes | **buf** | — |
| Python | `python` | `.py`, `.pyi`, `.pyw` | Yes | Yes | **basedpyright** → **pyright** → **ty** → **pyrefly** + **ruff** | **debugpy** |
+2 -1
Переглянути файл
@@ -238,7 +238,7 @@ The full table is `UI_KEYS` in
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
@@ -504,6 +504,7 @@ conventionally go in the theme file, groups marked _editor scheme_ in the scheme
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Lang
| `formatter` | 表 | 外部格式化工具,见下文。 |
| `scratch-template` | 字符串 | 新建该语言草稿文件时的内容,`$END$` 表示光标位置。 |
内置的 tree-sitter 语法:`bash`、`c`、`cmake`、`cpp`、`csharp`、`css`、`dart`、`elixir`、`gdscript`、`go`、`graphql`、`haskell`、`hcl`、`html`、`java`、`javascript`、`json`、`kotlin`、`lua`、`make`、`markdown`、`nix`、`ocaml`、`ocaml_interface`、`php`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
内置的 tree-sitter 语法:`bash`、`c`、`cmake`、`cpp`、`csharp`、`css`、`dart`、`elixir`、`gdscript`、`go`、`graphql`、`haskell`、`hcl`、`html`、`java`、`javascript`、`json`、`kotlin`、`lua`、`make`、`markdown`、`nix`、`ocaml`、`ocaml_interface`、`php`、`prisma`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
### `[[framework-ui-modes]]`
+1
Переглянути файл
@@ -146,6 +146,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
| PHP | `php` | `.php`, `.phtml`, `.php3`, `.php4`, `.php5`, … | 是 | 是 | **intelephense** → phpactor + **laravel-lsp** | **php-debug** |
| Plain Text | `plaintext` | `.txt`, `.text` | — | — | — | — |
| PowerShell | `powershell` | `.ps1`, `.psm1`, `.psd1`, `.pssc`, `.psrc` | 是 | — | powershell-editor-services | — |
| Prisma | `prisma` | `.prisma` | 是 | 是 | **prisma-language-server** | — |
| Properties | `properties` | `.properties`, `.prefs` | 是 | — | — | — |
| Protocol Buffers | `proto` | `.proto` | 是 | 是 | **buf** | — |
| Python | `python` | `.py`, `.pyi`, `.pyw` | 是 | 是 | **basedpyright** → **pyright** → **ty** → **pyrefly** + **ruff** | **debugpy** |
+2 -1
Переглянути файл
@@ -210,7 +210,7 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
| `--syn-rainbow-1` … `--syn-rainbow-5`(语义高亮) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES`(效果颜色) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT`(`CONSOLE_DARKGRAY_OUTPUT` 为亮黑,`CONSOLE_GRAY_OUTPUT` 为白色) |
@@ -469,6 +469,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+47
Переглянути файл
@@ -4,6 +4,7 @@
* by `crates/ide-lsp/tests`.
*/
import type { Locator, Page } from "@playwright/test";
import type { CompletionItem } from "../src/bindings";
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
@@ -78,6 +79,52 @@ test("completion lists the server's items and inserts the chosen one", async ({
expect((await ipcCalls(page, "lsp_completion")).length).toBeGreaterThan(0);
});
for (const key of ["Enter", "Tab"]) {
for (const prefix of ["", "v"]) {
test(`${key} leaves the caret after a field snippet at root.${prefix}`, async ({ workbench: page }, info) => {
const editor = await openSample(page);
await page.evaluate(() => {
const item = (label: string): CompletionItem => ({
label,
labelDetail: null,
labelDescription: "int",
detail: "int",
kind: "field",
documentation: null,
sortText: null,
filterText: null,
preselect: label === "val",
deprecated: false,
insertText: label,
isSnippet: true,
replaceRange: null,
replaceRangeTab: null,
additionalEdits: [],
commitCharacters: [],
command: null,
data: null,
server: 1,
});
window.__ipc.handlers.lsp_completion = () => ({ isIncomplete: false, items: [item("val"), item("value")] });
});
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
await page.keyboard.press("End");
await page.keyboard.press("Enter");
await page.keyboard.type(`int a = root.${prefix}`);
await page.keyboard.press("Control+Space");
const popup = page.locator(".cm-tooltip-autocomplete");
await expect(popup.getByText("val", { exact: true })).toBeVisible();
// CodeMirror ignores acceptance during its 75 ms lookup interaction delay.
await page.waitForTimeout(100);
await page.keyboard.press(key);
await expect(popup).toBeHidden();
await page.screenshot({ path: info.outputPath("accepted-completion.png"), animations: "disabled" });
await page.keyboard.type(";");
await expect(editor.locator(".cm-activeLine")).toHaveText(/^\s*int a = root\.val;$/);
});
}
}
test("⌘B goes to the declaration in another file", async ({ workbench: page }) => {
const editor = await openSample(page);
await page.evaluate(
+29
Переглянути файл
@@ -256,3 +256,32 @@ test("single-line selection stays on its own row when font glyphs exceed line he
expect(Math.abs(selBox!.y - lineBox!.y)).toBeLessThanOrEqual(1);
expect(Math.abs(selBox!.height - lineBox!.height)).toBeLessThanOrEqual(1);
});
test("vertical tabs leave a narrow editor group most of its width", async ({ workbench: page }) => {
await page.setViewportSize({ width: 900, height: 600 });
await openFromTree(page, "settings.gradle.kts");
await page.evaluate(async () => {
const projectModule = "/src/platform/project.ts";
const schemaModule = "/src/features/settings/model/schema.ts";
const controllerModule = "/src/editor/core/controller.ts";
const [{ useProject }, { DEFAULT_SETTINGS }, { editorController, useTabs }] = await Promise.all([
import(/* @vite-ignore */ projectModule),
import(/* @vite-ignore */ schemaModule),
import(/* @vite-ignore */ controllerModule),
]);
const base = useProject.getState().settings ?? DEFAULT_SETTINGS;
useProject.setState({
settings: { ...base, editor: { ...base.editor, tabs: { ...base.editor.tabs, placement: "left" } } },
});
editorController().split(useTabs.getState().activePane, "row");
});
const panes = page.locator("[data-pane-id]");
await expect(panes).toHaveCount(2);
for (const pane of await panes.all()) {
const strip = pane.getByRole("tablist", { name: "Editor tabs" });
await expect(strip).toHaveAttribute("aria-orientation", "vertical");
const [paneBox, contentBox] = await Promise.all([pane.boundingBox(), pane.getByRole("tabpanel").boundingBox()]);
expect(contentBox!.width).toBeGreaterThanOrEqual(paneBox!.width * 0.6 - 1);
}
});
+14
Переглянути файл
@@ -105,6 +105,20 @@ test("Find in Files streams matches by file and opens the chosen one at its line
});
const preview = dialog.getByRole("region", { name: "Preview", exact: true }).locator(".cm-content");
await expect(preview).toHaveAttribute("contenteditable", "true");
// Matches take the editor scheme's search result background (IDEA's SEARCH_RESULT_ATTRIBUTES) and keep the syntax colours.
const match = preview.locator(".cm-openidea-searchResult").first();
await expect(match).toHaveText("OrderService");
expect(
await match.evaluate((el) => {
const probe = document.createElement("span");
probe.style.backgroundColor = "var(--bg-search-result)";
el.parentElement!.append(probe);
const expected = getComputedStyle(probe).backgroundColor;
probe.remove();
const own = getComputedStyle(el);
return own.backgroundColor === expected && own.color === getComputedStyle(el.parentElement!).color;
}),
).toBe(true);
const originalPreview = await preview.textContent();
await preview.focus();
await page.keyboard.press("Meta+a");
+2
Переглянути файл
@@ -41,3 +41,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+2
Переглянути файл
@@ -75,6 +75,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -74,6 +74,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+17
Переглянути файл
@@ -0,0 +1,17 @@
id = "prisma"
name = "Prisma"
extensions = ["prisma"]
line-comment = "//"
indent = { tab-size = 2, insert-spaces = true }
tree-sitter = "prisma"
[[language-servers]]
name = "prisma-language-server"
command = "prisma-language-server"
args = ["--stdio"]
root-markers = ["package.json", "schema.prisma"]
install-hint = "npm i -g @prisma/language-server"
# Asked for as section `prisma` before every validation; a missing section crashes the server.
[language-servers.settings.prisma]
enableDiagnostics = true
+2
Переглянути файл
@@ -43,3 +43,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+2
Переглянути файл
@@ -89,6 +89,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -74,6 +74,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -68,3 +68,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+1
Переглянути файл
@@ -89,6 +89,7 @@
"--fg-editor-selection": "inherit",
"--bg-text-search-result": "#114957",
"--bg-text-search-result-current": "#165e70",
"--bg-search-result": "#2d543f",
"--bg-matched-brace": "#38424f",
"--matched-brace-weight": "700",
"--bg-matched-tag": "#353e4a",
+3 -2
Переглянути файл
@@ -231,7 +231,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../src/
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting, rainbow indent guides) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
@@ -329,7 +329,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
263 themeable tokens: 125 in the UI theme file, 138 in the editor colour scheme file.
264 themeable tokens: 125 in the UI theme file, 139 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -509,6 +509,7 @@ a token.
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+10
Переглянути файл
@@ -219,6 +219,16 @@ describe("IntelliJ theme import", () => {
});
});
it("keeps the search result colour apart from the text search result and the lists' search match", () => {
const bg = (c: string) => `<value><option name="BACKGROUND" value="${c}"/></value>`;
const s = convertIcls(`<scheme name="Search" parent_scheme="Darcula"><attributes>
<option name="SEARCH_RESULT_ATTRIBUTES">${bg("2d543f")}</option>
<option name="TEXT_SEARCH_RESULT_ATTRIBUTES">${bg("114957")}</option>
</attributes></scheme>`);
expect(s.tokens).toMatchObject({ "--bg-search-result": "#2d543f", "--bg-text-search-result": "#114957" });
expect(s.tokens).not.toHaveProperty("--bg-search-match");
});
it("produces an installable package", () => {
const light = stripJsonComments(themeJson.replace('"dark": true,', ""));
const c = convertIntellijTheme(light, icls.replace("Darcula", "Default"), "sea");
+17 -4
Переглянути файл
@@ -3,8 +3,9 @@
use crate::state::AppState;
use ide_agent::registry::AgentRegistry;
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpServer, McpServerInput, NewAgentTask,
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpServer, McpServerInput, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath, RequestId};
use ide_host::ops::agent;
@@ -370,7 +371,18 @@ pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: Str
agent::changes(&state.host, window.label(), task).await
}
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
/// The task's uncommitted changes and the commits made since it started.
#[tauri::command]
#[specta::specta]
pub async fn agent_grouped_changes(
window: Window,
state: State<'_, AppState>,
task: String,
) -> IdeResult<AgentChanges> {
agent::grouped_changes(&state.host, window.label(), task).await
}
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
#[tauri::command]
#[specta::specta]
pub async fn agent_rollback_changes(
@@ -378,8 +390,9 @@ pub async fn agent_rollback_changes(
state: State<'_, AppState>,
task: String,
paths: Option<Vec<String>>,
view: Option<AgentChangesView>,
) -> IdeResult<()> {
agent::rollback_changes(&state.host, window.label(), task, paths).await
agent::rollback_changes(&state.host, window.label(), task, paths, view).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
+2
Переглянути файл
@@ -154,6 +154,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
agent::agent_rename,
agent::agent_delete,
agent::agent_changes,
agent::agent_grouped_changes,
agent::agent_base_text,
agent::agent_rollback_changes,
agent::agent_accept_changes,
@@ -303,6 +304,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_branch_info,
vcs::vcs_line_hunks,
vcs::vcs_file_at,
vcs::vcs_file_mode,
vcs::vcs_blame,
vcs::vcs_block_history,
vcs::vcs_log,
+2 -4
Переглянути файл
@@ -111,10 +111,8 @@ pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc<ide_host
ide_language::set_external_syntax(Some(host.syntax_provider()));
ide_run::set_external_detection(Some(host.run_detection()));
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(project_host)))));
// Released builds list the official plugins (published with each release) until the user picks another index.
if !cfg!(debug_assertions) {
host.set_default_market_url(Some(ide_host::plugins::OFFICIAL_MARKET_INDEX.into()));
}
// The official plugins (published with each release) are listed until the user picks another index.
host.set_default_market_url(Some(ide_host::plugins::OFFICIAL_MARKET_INDEX.into()));
#[cfg(target_os = "macos")]
host.set_secret_store(Arc::new(KeychainSecrets));
ide_host::plugins::set_editor_providers(Some(&host));
+13
Переглянути файл
@@ -51,6 +51,19 @@ pub async fn vcs_file_at(
ide_host::ops::vcs::vcs_file_at(&state.host, window.label(), path, rev).await
}
/// The file's git mode at `rev` (`100644`, `100755`, `120000`…), `"WT"` for the
/// worktree; `None` when absent there. Lets the diff viewer report a mode-only change.
#[tauri::command]
#[specta::specta]
pub async fn vcs_file_mode(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
rev: Option<String>,
) -> IdeResult<Option<String>> {
ide_host::ops::vcs::vcs_file_mode(&state.host, window.label(), path, rev).await
}
/// History for Selection: commits that changed `lines` (0-based, inclusive) of `path`'s
/// editor text. Cancellable with `cancel_request(request)`.
#[tauri::command]
+1 -2
Переглянути файл
@@ -17,8 +17,7 @@ pub async fn web_browsers() -> IdeResult<Vec<Browser>> {
#[tauri::command]
#[specta::specta]
pub async fn web_url(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult<String> {
let root = state.project(window.label()).ok().map(|p| p.root.clone());
state.web.url_for(root.as_deref(), &path).await
ide_host::ops::web::url(&state.host, window.label(), &path).await
}
/// Opens `url` in `browser` (an id from `web_browsers`), or the default browser.
+36 -1
Переглянути файл
@@ -76,6 +76,8 @@ pub struct Connection {
/// Remote ports forwarded to this machine (Ports tool window), by host (`None`: the remote
/// host itself) and remote port.
forwards: Mutex<Vec<(ForwardKey, Forward)>>,
/// Ports of the remote built-in web server forwarded here, by remote port.
web_forwards: Mutex<Vec<(u16, Forward)>>,
}
/// A forwarded port's host (`None`: the remote host itself) and remote port.
@@ -544,6 +546,7 @@ pub async fn remote_connect(
next_channel: std::sync::atomic::AtomicU64::new(1),
session,
forwards: Mutex::new(Vec::new()),
web_forwards: Mutex::new(Vec::new()),
exec,
});
if let RemoteTarget::DevContainer { folder, config } = &target {
@@ -749,6 +752,34 @@ async fn open_forward(c: &Connection, host: Option<&str>, remote_port: u16) -> I
Err(IdeError::unavailable("Forwarding ports is not supported in a WSL project yet"))
}
/// `web_url` answers with the remote host's built-in web server, on its loopback: the URL's
/// port is forwarded here. A WSL distribution shares this machine's loopback already.
async fn forward_web_url(c: &Connection, url: &str) -> IdeResult<String> {
let Some((port, path)) = url.strip_prefix("http://127.0.0.1:").and_then(|rest| rest.split_once('/')) else {
return Ok(url.to_owned());
};
let Ok(remote) = port.parse::<u16>() else { return Ok(url.to_owned()) };
let existing = c.web_forwards.lock().iter().find(|(p, _)| *p == remote).map(|(_, f)| f.port());
let local = match existing {
Some(local) => local,
None => {
let forward = if let Some(session) = c.session.clone() {
Forward::Ssh(ide_ssh::Tunnel::listen_on(session, Some(remote), "127.0.0.1", remote).await?)
} else if let Some(exec) = c.exec.clone() {
Forward::Container(
ide_remote::container_tunnel::Tunnel::listen(exec, remote, "127.0.0.1", remote).await?,
)
} else {
return Ok(url.to_owned());
};
let local = forward.port();
c.web_forwards.lock().push((remote, forward));
local
}
};
Ok(format!("http://127.0.0.1:{local}/{path}"))
}
/// Forwards the `forwardPorts` of the dev container's configuration (the local folder's).
async fn forward_configured_ports(c: &Connection, folder: &str, config: &str) {
let local = std::path::PathBuf::from(folder);
@@ -858,8 +889,12 @@ pub async fn remote_invoke(
} else {
None
};
let web_url = cmd == "web_url";
let call = Invoke { window: label, cmd, args };
let result = invoke_reply(&c.peer, call, std::time::Duration::from_secs(30)).await?;
let mut result = invoke_reply(&c.peer, call, std::time::Duration::from_secs(30)).await?;
if web_url && let Some(url) = result.as_str() {
result = Value::String(forward_web_url(&c, url).await?);
}
if let (Some((peers, edit)), Some(version)) =
(mirrored, result.as_u64().and_then(|version| u32::try_from(version).ok()))
{
-3
Переглянути файл
@@ -19,8 +19,6 @@ pub struct AppState {
pub recent: RecentProjects,
/// Plugins (`ide_host::plugins`), shared by all windows.
pub plugins: Arc<ide_host::plugins::PluginHost>,
/// The built-in web server (Open In › Browser, the editor's browser bar).
pub web: ide_web::WebServer,
/// Agents › MCP Server: built-in IDE tools for agents.
pub mcp: crate::commands::mcp::McpService,
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
@@ -108,7 +106,6 @@ impl AppState {
askpass: start_askpass(&app),
sink: global_sink,
plugins: crate::commands::plugin::create_host(&app, &data_dir, &host)?,
web: ide_web::WebServer::new(),
mcp: Default::default(),
remote_dev: crate::remote_dev::Connections::default(),
host,
+59 -5
Переглянути файл
@@ -214,10 +214,16 @@ export const commands = {
agentRename: (task: string, title: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rename", { task, title })),
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
/** The task's uncommitted changes and the commits made since it started. */
agentGroupedChanges: (task: string) => typedError<AgentChanges, IdeError>(__TAURI_INVOKE("agent_grouped_changes", { task })),
/** Content of `path` when the task started (left side of its diff). */
agentBaseText: (task: string, path: IpcPath) => typedError<string | null, IdeError>(__TAURI_INVOKE("agent_base_text", { task, path })),
/** Rollback: the task's changed files among `paths` (all without) back as they were when it started. */
agentRollbackChanges: (task: string, paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths })),
/** Rollback: the task's changed files among `paths` (all without) back as `view` compares them. */
agentRollbackChanges: (task: string, paths: string[] | null, view:
/** Everything since the commit the task started from. */
"sinceStart" |
/** The uncommitted files against `HEAD`; commits made since the start stay. */
"group" | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths, view })),
/**
* Accept: copies a worktree task's changed files among `paths` (all without) into the
* local checkout, uncommitted; files the checkout changed itself are left alone and reported.
@@ -610,6 +616,11 @@ export const commands = {
/** Gutter change markers for a file, computed against its current (unsaved) text. */
vcsLineHunks: (path: string) => typedError<LineHunk[], IdeError>(__TAURI_INVOKE("vcs_line_hunks", { path })),
vcsFileAt: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_at", { path, rev })),
/**
* The file's git mode at `rev` (`100644`, `100755`, `120000`…), `"WT"` for the
* worktree; `None` when absent there. Lets the diff viewer report a mode-only change.
*/
vcsFileMode: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_mode", { path, rev })),
vcsBlame: (path: string, text: string | null, revision: string | null) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path, text, revision })),
/**
* History for Selection: commits that changed `lines` (0-based, inclusive) of `path`'s
@@ -1732,15 +1743,38 @@ export type AgentCapabilities = {
backgroundTasks?: boolean,
};
/** A file the task changed, relative to [`AgentTask::base_commit`]. */
/**
* A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
* grouped view.
*/
export type AgentChange = {
path: IpcPath,
relPath: string,
kind: ChangeKind,
added: number,
removed: number,
/** Path before a rename; the left side of its diff reads this path. */
oldPath: string | null,
};
/** A task's uncommitted files and the commits made since it started. */
export type AgentChanges = {
/** Files changed against `HEAD`, less those changed before the task started and untouched since. */
uncommitted: AgentChange[],
/**
* Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
* for tasks working in the project's own checkout.
*/
commits: AgentCommit[],
};
/** Which comparison a rollback of the task's changes uses. */
export type AgentChangesView =
/** Everything since the commit the task started from. */
"sinceStart" |
/** The uncommitted files against `HEAD`; commits made since the start stay. */
"group";
export type AgentCommand = {
/** Without the leading `/`. */
name: string,
@@ -1755,6 +1789,18 @@ export type AgentCommandReadOnlyReason = "packageRunner" | "registry" | "customA
export type AgentCommandSource = "command" | "skill";
/** A commit made while the task ran, with the files it changed against its first parent. */
export type AgentCommit = {
id: string,
shortId: string,
summary: string,
message: string,
timeMs: number | null,
/** First parent, or `None` for a root commit. */
parent: string | null,
files: AgentChange[],
};
/** A setting chosen for a new task, by [`AgentConfigOption::id`]. */
export type AgentConfigChoice = {
id: string,
@@ -6849,7 +6895,11 @@ export type MavenProject = {
profiles: string[],
/** Java feature version the project compiles for (`maven.compiler.release`, `java.version`…). */
javaVersion: number | null,
/** Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`. */
/**
* Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened
* project the search stops at its root; outside it, the search stops before
* that root so a linked project keeps its own wrapper.
*/
command: string,
/** Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings. */
options: string[],
@@ -6863,7 +6913,11 @@ export type MavenSettings = {
runnerJre?: string,
/** Project-relative root POMs removed from the Maven project model. */
unlinkedProjects?: string[],
/** Project-relative POMs outside the root reactor added as independent top-level projects. */
/**
* POMs added as independent top-level projects. Inside the opened project the path is
* project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking
* does not edit either POM.
*/
linkedProjects?: string[],
/** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */
home?: string,
+2
Переглянути файл
@@ -332,6 +332,8 @@ export const openideaTheme = EditorView.theme({
// Find in file (cm/find.ts)
...findStyles,
// Find in Files preview matches (search/find/parts.tsx)
".cm-openidea-searchResult": { backgroundColor: "var(--bg-search-result)" },
// Overlays
".cm-tooltip": {
+3 -2
Переглянути файл
@@ -271,8 +271,9 @@
.tabsVertical > .tabs,
.tabsVertical > .tabRows {
width: max-content;
min-width: 140px;
max-width: 260px;
/* A narrow editor group keeps most of its width for the editor; labels truncate. */
min-width: min(140px, 40%);
max-width: min(260px, 40%);
border-bottom: none;
border-right: 1px solid var(--border-editor-tabs, var(--border));
}
+38
Переглянути файл
@@ -318,6 +318,44 @@ describe("applying completions", () => {
});
for (const mode of ["insert", "replace"] as const) {
for (const prefix of ["", "v"]) {
it(`${mode} leaves the caret after a field snippet accepted at root.${prefix}`, () => {
const { view, req, pos } = setup(`int a = root.${prefix}|Suffix`);
const start = "int a = root.".length;
const c = makeCompletion(
item({
label: "val",
kind: "field",
insertText: "val",
isSnippet: true,
replaceRange: { start: { line: 0, col: start }, end: { line: 0, col: pos } },
}),
req,
);
applyLspCompletion(view, c, start, pos, mode);
expect(view.state.doc.toString()).toBe(`int a = root.val${mode === "insert" ? "Suffix" : ""}`);
expect(view.state.selection.main.head).toBe(start + 3);
});
}
it(`${mode} places a fieldless snippet after imports and a cached UTF-16 prefix`, () => {
const { view, req, pos } = setup("\n/* 😀 */ root.|Suffix", "v");
const c = makeCompletion(
item({
label: "val",
kind: "field",
insertText: "val",
isSnippet: true,
replaceRange: { start: { line: 1, col: pos - 1 }, end: { line: 1, col: pos - 1 } },
additionalEdits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "import X;" }],
}),
req,
);
applyLspCompletion(view, c, pos, pos + 1, mode);
expect(view.state.doc.toString()).toBe(`import X;\n/* 😀 */ root.val${mode === "insert" ? "Suffix" : ""}`);
expect(view.state.selection.main.head).toBe(pos + "import X;val".length);
});
for (const shape of ["textEdit", "insertReplace", "noEdit"] as const) {
for (const isSnippet of [false, true]) {
it(`${mode} replaces a mixed-case prefix typed after a ${shape} request (snippet: ${isSnippet})`, () => {
+57
Переглянути файл
@@ -146,6 +146,63 @@ 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({
+47 -13
Переглянути файл
@@ -299,27 +299,61 @@ 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 = (await fetchActions(view, [kind], true)).filter(
(a) => a.kind?.startsWith(kind) && !a.disabledReason && !isLazyPluginAction(a),
);
} catch {
actions = matching(await fetchActions(view, [kind], true));
} catch (e) {
console.warn(`[lsp] ${kind} on ${doc.path} failed`, e);
return false;
}
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;
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));
}
for (let i = 0; i < 25 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20));
return true;
return applied;
}
/**
+29 -4
Переглянути файл
@@ -23,7 +23,7 @@ describe("lspSnippetToCm", () => {
});
it("handles LSP escapes", () => {
expect(lspSnippetToCm("cost \\$5 \\} \\\\")).toBe("cost $5 \\} \\");
expect(lspSnippetToCm("cost \\$5 \\} \\\\")).toBe("cost $5 \\} \\" + "${0}");
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");
expect(lspSnippetToCm("${NOPE:dflt}", vars)).toBe("dflt");
expect(lspSnippetToCm("class $TM_FILENAME_BASE", vars)).toBe("class Main${0}");
expect(lspSnippetToCm("${NOPE:dflt}", vars)).toBe("dflt${0}");
expect(lspSnippetToCm("$NOPE", vars)).toBe("${NOPE}");
});
@@ -51,7 +51,7 @@ describe("lspSnippetToCm", () => {
});
it("treats a lone dollar as text", () => {
expect(lspSnippetToCm("$ x $")).toBe("$ x $");
expect(lspSnippetToCm("$ x $")).toBe("$ x $${0}");
});
it("selects the first field when instantiated", () => {
@@ -60,6 +60,31 @@ 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)");
});
+7 -2
Переглянути файл
@@ -189,8 +189,12 @@ 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 parse(snippet)) {
for (const n of nodes) {
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] ?? "");
@@ -202,7 +206,8 @@ export function lspSnippetToCm(snippet: string, vars: SnippetVariables = {}): st
else out += field(n.name, "");
}
}
return out;
// CodeMirror leaves the caret before an insertion without fields.
return hasField ? out : out + "${0}";
}
/** The snippet's text with every field replaced by its default (for previews / plain insertion). */
+7
Переглянути файл
@@ -62,6 +62,7 @@ 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;
@@ -454,6 +455,12 @@ export function Composer(props: ComposerProps) {
recalled.current = { index: -1, stash: "" };
return;
}
// macOS text boxes scroll on Home / End and leave the caret where it is.
if ((e.key === "Home" || e.key === "End") && !e.altKey && !e.ctrlKey && !e.metaKey && isMacHost()) {
e.preventDefault();
getSelection()?.modify(e.shiftKey ? "extend" : "move", e.key === "Home" ? "backward" : "forward", "lineboundary");
return;
}
if (e.key === "Tab" && e.shiftKey) {
const mode = modeOption(props.config);
const value = mode && nextValue(mode);
+41
Переглянути файл
@@ -1102,6 +1102,47 @@
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);
}
+305 -43
Переглянути файл
@@ -20,6 +20,7 @@ import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import type {
AgentChange,
AgentCommit,
AgentEntry,
AgentImage,
AgentLimit,
@@ -57,7 +58,9 @@ import {
} from "@/ui";
import { useTx, tx } from "@/platform/i18n";
import { isImeKey } from "@/platform/ime";
import { openDiff, withChain, type DiffData } from "@/features/vcs/diffInput";
import { commitFileDiffData, openDiff, withChain, type DiffData } from "@/features/vcs/diffInput";
import { repositoryPath } from "@/features/vcs/repositoryPaths";
import { setChangesOptions, useChangesOptions } from "./changesOptions";
import { Composer } from "./Composer";
import { selectionIn } from "./selection";
import { SubmitButton } from "./SubmitButton";
@@ -112,6 +115,7 @@ import {
markVisible,
refreshChanges,
rollbackChanges,
scheduleChanges,
editQueued,
forkTask,
rerunFrom,
@@ -136,7 +140,13 @@ 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));
const changes = useAgents((s) => s.changes.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 project = useProject((s) => s.project);
const head = useLocalHead(
task?.isolation === "local" ? task.status : null,
@@ -161,6 +171,11 @@ 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(() => {
@@ -224,6 +239,7 @@ 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}
@@ -235,6 +251,8 @@ 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)}
@@ -2410,6 +2428,7 @@ function Tray({
task,
entries,
changes,
commits,
changesOpen,
onToggleChanges,
children,
@@ -2417,6 +2436,7 @@ function Tray({
task: AgentTask;
entries: AgentEntry[] | null;
changes: AgentChange[] | null;
commits: readonly AgentCommit[];
changesOpen: boolean;
onToggleChanges: () => void;
children: ReactNode;
@@ -2434,7 +2454,7 @@ function Tray({
return (
<div className={styles.tray}>
{pending && <PendingPermission task={task} entry={pending} />}
{(planShown || !!changes?.length) && (
{(planShown || !!changes?.length || !!commits.length) && (
<div className={styles.trayStrip}>
{planShown && (
<button
@@ -2458,6 +2478,12 @@ 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 && (
@@ -2571,6 +2597,7 @@ function SessionComposer({
entries,
head,
changes,
commits,
changesOpen,
onToggleChanges,
onOpenSubagent,
@@ -2579,6 +2606,7 @@ function SessionComposer({
entries: AgentEntry[] | null;
head: string | null;
changes: AgentChange[] | null;
commits: readonly AgentCommit[];
changesOpen: boolean;
onToggleChanges: () => void;
onOpenSubagent: (id: string) => void;
@@ -2637,7 +2665,14 @@ function SessionComposer({
/>
</div>
)}
<Tray task={task} entries={entries} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges}>
<Tray
task={task}
entries={entries}
changes={changes}
commits={commits}
changesOpen={changesOpen}
onToggleChanges={onToggleChanges}
>
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
<BackgroundTasks task={task} />
{task.queue.length > 0 && <Queue task={task} />}
@@ -2796,25 +2831,115 @@ 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;
}) {
const [selected, setSelected] = useState<Set<string>>(() => new Set());
// 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 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 chosen = (changes ?? []).filter((c) => selected.has(c.path));
const count = chosen.length || changes?.length || 0;
const count = picked.length || changes?.length || 0;
if (!count) return;
const ok = await confirm({
title: "Rollback Changes",
@@ -2822,15 +2947,17 @@ function ChangesPane({
<>
<p>
{count === 1
? tx`Roll back the changes to ${basename((chosen[0] ?? changes![0]!).path)}?`
? tx`Roll back the changes to ${basename((picked[0] ?? changes![0]!).path)}?`
: tx`Roll back the changes to ${count} files?`}
</p>
<p>
{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.",
)}
{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.",
)}
</p>
</>
),
@@ -2839,7 +2966,11 @@ function ChangesPane({
});
if (!ok) return;
try {
await rollbackChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null);
await rollbackChanges(
task.id,
picked.length ? picked.map((c) => c.relPath) : null,
grouped ? "group" : "sinceStart",
);
setSelected(new Set());
} catch (e) {
notifications().notify({
@@ -2850,10 +2981,15 @@ function ChangesPane({
}
};
const accept = async () => {
const chosen = (changes ?? []).filter((c) => selected.has(c.path));
const count = chosen.length || changes?.length || 0;
// 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;
if (!count) return;
const name = basename((chosen[0] ?? changes![0]!).path);
const name = basename(targets[0]!.path);
const ok = await confirm({
title: "Accept Changes",
message: (
@@ -2871,7 +3007,7 @@ function ChangesPane({
});
if (!ok) return;
try {
const outcome = await acceptChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null);
const outcome = await acceptChanges(task.id, picked.length ? picked.map((c) => c.relPath) : null);
setSelected(new Set());
if (outcome.skipped.length) {
const skippedNames = outcome.skipped.map((s) => s.path).join(", ");
@@ -2907,23 +3043,44 @@ function ChangesPane({
{task.isolation === "worktree" && (
<IconButton
icon="check"
title={selected.size ? "Accept Selected Changes…" : "Accept All Changes…"}
disabled={!changes?.length || task.status === "starting" || task.status === "running"}
title={picked.length ? "Accept Selected Changes…" : "Accept All Changes…"}
disabled={(!changes?.length && !commits.length) || task.status === "starting" || task.status === "running"}
onClick={() => void accept()}
/>
)}
<IconButton
icon="revert"
title={selected.size ? "Rollback Selected Changes…" : "Rollback All Changes…"}
title={picked.length ? "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>
{(head ?? task.base) && (
{(grouped || head || task.base) && (
<div className={styles.changesSummary}>
<T>vs</T> {head ?? task.base}
<T>vs</T> {grouped ? "HEAD" : (head ?? task.base)}
{changes && changes.length > 0 && (
<>
{" "}
@@ -2936,29 +3093,81 @@ function ChangesPane({
{changes === null ? (
<Spinner />
) : (
<Tree<AgentChange>
<Tree<ChangeRow>
key={grouped ? "grouped" : "flat"}
aria-label="Changed files"
roots={changes}
getKey={(c) => c.path}
getLabel={(c) => basename(c.path)}
getChildren={() => undefined}
isExpandable={() => false}
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"}
selected={selected}
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)}
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>
</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>
@@ -2990,3 +3199,56 @@ export async function openChangeDiff(task: AgentTask, path: string) {
);
await openDiff(withChain(diffs, paths.indexOf(path)));
}
/** Diff of `path` in the grouped view: `HEAD` ↔ the working file. */
function uncommittedDiffData(task: AgentTask, change: AgentChange): DiffData {
const left: DiffData["left"] =
change.kind === "added" || change.kind === "untracked"
? { type: "text", text: "", label: "(does not exist)", absent: true }
: {
type: "rev",
rev: "HEAD",
path: change.oldPath ? repositoryPath(task.cwd, change.oldPath) : undefined,
label: "HEAD",
};
const right: DiffData["right"] =
change.kind === "deleted"
? { type: "text", text: "", label: "(deleted)", absent: true }
: { type: "worktree", label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree" };
return { path: change.path, left, right };
}
/** Registration needed before a diff reads revisions inside the task's own worktree. */
async function ensureTaskRepo(task: AgentTask) {
// A worktree is its own repository, outside the project's: `vcs_file_at` reads it only once
// it is registered as an external log for this window.
if (task.isolation === "worktree") await call(commands.vcsOpenExternalLog(task.cwd)).catch(() => undefined);
}
/** Opens the diff of `path` among the task's uncommitted files (grouped view). */
async function openUncommittedDiff(task: AgentTask, path: string) {
const changes = useAgents.getState().groupedChanges.get(task.id)?.uncommitted ?? [];
const paths = changes.map((change) => change.path);
if (!paths.includes(path)) return;
await ensureTaskRepo(task);
await openDiff(
withChain(
changes.map((change) => uncommittedDiffData(task, change)),
paths.indexOf(path),
),
);
}
/** Opens the diff of one commit (`parent ↔ commit`), starting at `path`. */
async function openCommitDiff(task: AgentTask, commit: AgentCommit, path: string) {
const diffs = commit.files.map((file) =>
commitFileDiffData(
{ id: commit.id, shortId: commit.shortId, parents: commit.parent ? [commit.parent] : [], repo: task.cwd },
{ path: file.relPath, kind: file.kind, oldPath: file.oldPath },
),
);
if (!diffs.length) return;
await ensureTaskRepo(task);
const index = commit.files.findIndex((file) => file.path === path);
await openDiff(withChain(diffs, index < 0 ? 0 : index));
}
+36
Переглянути файл
@@ -0,0 +1,36 @@
/** 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.
}
}
+31 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, 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,6 +43,7 @@ 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";
@@ -243,6 +244,35 @@ describe("New Task composer commands", () => {
});
});
describe("New Task composer Home / End", () => {
const os = usePlatform.getState().os;
afterEach(() => {
usePlatform.setState({ os });
vi.restoreAllMocks();
});
it("moves the caret to the line's start or end on macOS, extending the selection with Shift", () => {
usePlatform.setState({ os: "macos" });
const modify = vi.fn();
vi.spyOn(window, "getSelection").mockReturnValue({ modify } as unknown as Selection);
render(<HomeEditor />);
const box = screen.getByRole("textbox");
expect(fireEvent.keyDown(box, { key: "Home" })).toBe(false);
fireEvent.keyDown(box, { key: "End", shiftKey: true });
fireEvent.keyDown(box, { key: "End", metaKey: true });
expect(modify.mock.calls).toEqual([
["move", "backward", "lineboundary"],
["extend", "forward", "lineboundary"],
]);
});
it("leaves Home / End to the text box elsewhere", () => {
usePlatform.setState({ os: "windows" });
render(<HomeEditor />);
expect(fireEvent.keyDown(screen.getByRole("textbox"), { key: "Home" })).toBe(true);
});
});
describe("New Task composer paste", () => {
it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => {
render(<HomeEditor />);
+1
Переглянути файл
@@ -8,6 +8,7 @@ vi.mock("@/platform/ipc", async (original) => ({
commands: {
agentEntries: ipc.entries,
agentChanges: async () => ({ status: "ok", data: [] }),
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
},
}));
+1
Переглянути файл
@@ -15,6 +15,7 @@ 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,
+93 -2
Переглянути файл
@@ -4,11 +4,12 @@ import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { EditorShownProvider } from "@/workbench/contributions";
import { hideContextMenu, useContextMenuStore } from "@/ui";
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import type { AgentChange, AgentCommit, 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";
@@ -18,6 +19,8 @@ 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,
@@ -51,7 +54,11 @@ vi.mock("@/platform/ipc", async (original) => ({
return { status: "ok", data: backend.entries.get(key) ?? [] };
},
agentChanges: async () => ({ status: "ok", data: backend.changes }),
agentRollbackChanges: async () => ({ status: "ok", data: null }),
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 };
},
agentAcceptChanges: async (task: string, paths: string[] | null) => {
backend.accepted.push([task, paths]);
return { status: "ok", data: backend.acceptedOutcome };
@@ -156,7 +163,11 @@ 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 = [];
@@ -1200,6 +1211,7 @@ 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 }));
@@ -1381,6 +1393,7 @@ describe("accept changes", () => {
kind: "modified",
added: 8,
removed: 2,
oldPath: null,
},
];
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
@@ -1416,6 +1429,7 @@ describe("accept changes", () => {
kind: "modified",
added: 8,
removed: 2,
oldPath: null,
},
];
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
@@ -1425,3 +1439,80 @@ 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]]));
});
});
+34 -4
Переглянути файл
@@ -5,6 +5,8 @@ import { create } from "zustand";
import type {
AgentAcceptOutcome,
AgentChange,
AgentChanges,
AgentChangesView,
AgentCommand,
AgentConfigOption,
AgentEntry,
@@ -16,6 +18,7 @@ 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";
@@ -77,6 +80,8 @@ 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[];
@@ -147,6 +152,7 @@ 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, {})),
@@ -312,6 +318,7 @@ export async function connect(): Promise<void> {
entries: new Map(),
entryLoads: new Map(),
changes: new Map(),
groupedChanges: new Map(),
providers,
});
notifyTabTitlesChanged();
@@ -372,6 +379,7 @@ export function disconnect() {
entries: new Map(),
entryLoads: new Map(),
changes: new Map(),
groupedChanges: new Map(),
docked: null,
lastTask: null,
homeOpen: false,
@@ -522,6 +530,23 @@ 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) => ({
@@ -533,10 +558,14 @@ export async function refreshChanges(task: string): 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> {
/** 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> {
try {
await call(commands.agentRollbackChanges(task, relPaths));
await call(commands.agentRollbackChanges(task, relPaths, view));
} finally {
await refreshChanges(task);
}
@@ -552,7 +581,8 @@ export async function acceptChanges(task: string, relPaths: string[] | null): Pr
}
const pendingChanges = new Map<string, ReturnType<typeof setTimeout>>();
function scheduleChanges(task: string) {
/** Reloads the task's changes after a short pause (tool diffs, commits landing). */
export function scheduleChanges(task: string) {
if (pendingChanges.has(task)) return;
pendingChanges.set(
task,
+2
Переглянути файл
@@ -55,6 +55,7 @@ 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 }),
},
@@ -413,6 +414,7 @@ describe("the diff of an agent change", () => {
kind: "modified" as const,
added: 1,
removed: 1,
oldPath: null,
});
it("lists the task's other changed files, so F7 moves on to the next one", async () => {
+25
Переглянути файл
@@ -80,6 +80,25 @@ 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();
@@ -235,6 +254,12 @@ 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" },
+4 -1
Переглянути файл
@@ -1,8 +1,11 @@
/* File › New Project… */
.dialog {
height: min(602px, calc(100vh - 48px));
}
.layout {
display: grid;
grid-template-columns: 220px 1fr;
height: 520px;
height: 100%;
min-height: 0;
}
.sidebar {
+1
Переглянути файл
@@ -80,6 +80,7 @@ describe("build tools feature", () => {
"maven.analyzeDependencies",
"maven.executeGoal",
"maven.generateSources",
"maven.importExternal",
"maven.reload",
"maven.settings",
"maven.toggleOffline",
+14
Переглянути файл
@@ -35,6 +35,7 @@ import {
import {
ANALYZER_KIND,
addProjects,
importModuleFromExistingSources,
linkGradleProjects,
unlinkGradleProjects,
requestSync,
@@ -280,11 +281,24 @@ 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",
+119 -6
Переглянути файл
@@ -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 { useBuild } from "./store";
import { classifyPickedPoms, mavenPomForImport, useBuild } from "./store";
const mocks = vi.hoisted(() => ({
get: vi.fn(),
@@ -18,6 +18,7 @@ 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(),
@@ -41,6 +42,7 @@ vi.mock("@/platform/ipc", async (original) => {
gradleLinkProjects: mocks.link,
gradleUnlinkProjects: mocks.unlink,
buildSync: mocks.sync,
fsReadDir: (path: string) => mocks.readDir(path),
cancelRequest: () => Promise.resolve(),
},
};
@@ -241,8 +243,8 @@ it("re-adds a picked POM using the plus button and preserves other unlinked root
);
});
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",
it.each([null, "/p/a/build.xml", "/p/a/pom.xml", "not-a-file"])(
"ignores cancelled, invalid or already linked picks: %s",
async (picked) => {
mocks.open.mockResolvedValue(picked);
await executeAction("maven.addProjects");
@@ -250,6 +252,76 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/
},
);
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" });
@@ -272,8 +344,8 @@ it("links `\\\\?\\`-prefixed and differently-cased picks on Windows", async () =
}
});
it("warns about picks outside the project instead of silently doing nothing", async () => {
mocks.open.mockResolvedValue("/other/pom.xml");
it("warns about a pick that is not a pom.xml instead of silently doing nothing", async () => {
mocks.open.mockResolvedValue("/other/readme.xml");
await act(async () => {
await executeAction("maven.addProjects");
});
@@ -282,11 +354,31 @@ it("warns about picks outside the project instead of silently doing nothing", as
expect.objectContaining({
level: "warning",
title: expect.stringContaining("not added"),
body: expect.stringContaining("/other/pom.xml"),
body: expect.stringContaining("/other/readme.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"] } } },
@@ -304,6 +396,27 @@ it("links picked POMs that no reactor lists as independent top-level projects",
);
});
it("unlinking an external project removes the link and does not delete it from disk", async () => {
mocks.get.mockResolvedValue({ languages: { java: { maven: { linkedProjects: ["/B/pom.xml"] } } } });
const external = {
...model.maven!.projects[0]!,
pom: "/B/pom.xml",
dir: "/B",
artifactId: "b",
displayName: "project-b",
};
const linked: BuildModel = { ...model, maven: { ...model.maven!, projects: [...model.maven!.projects, external] } };
mocks.model.mockResolvedValue(linked);
await act(async () => {
useBuild.setState({ model: linked });
});
await act(async () => {
await executeAction("maven.unlinkProjects", { pom: "/B/pom.xml" });
});
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", "[]");
expect(mocks.set).not.toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", expect.anything());
});
it("unlinking a linked independent project removes the link instead of hiding it", async () => {
mocks.get.mockResolvedValue({ languages: { java: { maven: { linkedProjects: ["a/pom.xml"] } } } });
await act(async () => {
+13
Переглянути файл
@@ -228,6 +228,19 @@ 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