Compare commits

..
Author SHA1 Message Date
l0ng-ai cbcd1818a7 fix(plugins): accept empty navigation anchor ranges as positions
A document indexer that anchors a whole-file definition at the file's start
(an empty range at 0:0) had the whole document rejected with "invalid
navigation anchors". Empty ranges inside the document are now accepted:
Go to Declaration lands on them, while Find Usages and Rename skip them so
a rename never inserts the new name at that position. The error for a
document that is still rejected names the plugin and the file.
2026-11-12 00:11:01 +08:00
262 changed files with 677 additions and 8131 deletions

No files matched your search

-1
View File
@@ -12,7 +12,6 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"src/editor/core/cm/django/parser*.ts",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
Generated
+1 -66
View File
@@ -4618,7 +4618,6 @@ name = "ide-lsp"
version = "0.1.42"
dependencies = [
"dunce",
"encoding_rs",
"futures",
"globset",
"ide-core",
@@ -4845,15 +4844,11 @@ dependencies = [
"parking_lot",
"russh",
"russh-sftp",
"rustls",
"rustls-platform-verifier",
"serde",
"serde_json",
"specta",
"suppaftp",
"tempfile",
"tokio",
"tokio-rustls",
"tracing",
"uuid",
]
@@ -5552,29 +5547,6 @@ dependencies = [
"libc",
]
[[package]]
name = "lazy-regex"
version = "3.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0a2cce8a22920a29fdcc8c9170aa5d29edd9fbcf8bea856e24c2cb1c2419f67"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f1e4b1f1d8c71edb4ff2b2150ec4606445fd2ddf67ddc097655daf59286baa6"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 3.0.9",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -6941,26 +6913,6 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -9009,23 +8961,6 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "suppaftp"
version = "12.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b58fcf921745cca6f0127c9f822069947da8c6dbeff52e9004750bcc448171f"
dependencies = [
"async-trait",
"chrono",
"lazy-regex",
"log",
"pin-project",
"rustls-pki-types",
"thiserror 2.0.21",
"tokio",
"tokio-rustls",
]
[[package]]
name = "swift-rs"
version = "1.0.9"
@@ -9634,7 +9569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
+15 -20
View File
@@ -1338,19 +1338,15 @@ impl AgentManager {
/// Files the task changed since it started.
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
let task = self.task(id)?;
let at = task.base();
let (cwd, base) = task.cwd_and_base();
let before = task.state.lock().before.clone();
blocking(move || at.changes(&before)).await
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &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();
blocking(move || match at.rev()? {
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
None => Ok(None),
})
.await
let (cwd, base) = self.task(id)?.cwd_and_base();
blocking(move || worktree::file_at(&cwd, &path, &base)).await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
@@ -1360,17 +1356,16 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let at = task.base();
let (cwd, base) = task.cwd_and_base();
let (before, saved) = {
let s = task.state.lock();
(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 = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
let mut restamped = Vec::new();
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
@@ -1474,15 +1469,15 @@ impl Task {
})
}
fn base(&self) -> worktree::TaskBase {
fn cwd_and_base(&self) -> (PathBuf, String) {
let s = self.state.lock();
let cwd = s.task.cwd.0.clone();
match s.task.isolation {
Isolation::Local => worktree::TaskBase::Local { cwd, start: s.task.base_commit.clone() },
Isolation::Worktree => {
worktree::TaskBase::Worktree { cwd, base: s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into()) }
}
}
// In the project's own checkout the user commits too: compare against the current HEAD,
// as the Commit tool window does, so commits made since the task started drop out.
let base = match s.task.isolation {
Isolation::Local => None,
Isolation::Worktree => s.task.base_commit.clone(),
};
(s.task.cwd.0.clone(), base.unwrap_or_else(|| "HEAD".into()))
}
fn emit_task(&self) {
-152
View File
@@ -2869,158 +2869,6 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
/// A git repository in a temporary folder, for local tasks whose checkout moves under them.
struct Checkout {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
impl Checkout {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = dunce::canonicalize(tmp.path()).unwrap();
let checkout = Self { _tmp: tmp, dir };
checkout.git(&["init", "-q", "-b", "main"]);
checkout.git(&["config", "core.autocrlf", "false"]);
checkout.write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
checkout
}
fn git(&self, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&self.dir)
.status()
.unwrap()
.success();
assert!(ok, "{args:?}");
}
fn commit(&self, message: &str) {
self.git(&["add", "."]);
self.git(&["commit", "-q", "-m", message]);
}
fn write(&self, rel: &str, text: &str) {
std::fs::write(self.dir.join(rel), text).unwrap();
}
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.dir.join(rel)).unwrap()
}
/// A local task, stopped so it can be rolled back.
async fn task(&self) -> (AgentManager, AgentTask) {
let mgr = manager(&self.dir, Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
(mgr, task)
}
}
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.write("mine.txt", "base\n");
repo.commit("init");
repo.write("mine.txt", "my work\n");
repo.commit("my work");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The user takes the last commit back into the working tree (`git reset --soft`, an
// undone commit): its changes are the user's, not the agent's.
repo.git(&["reset", "-q", "--soft", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"]);
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
assert_eq!(base.as_deref(), Some("base\n"));
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n");
assert_eq!(repo.read("mine.txt"), "my work\n", "the user's work survives the rollback");
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.write("other.txt", "base\n");
repo.commit("init");
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.write("other.txt", "feature\n");
repo.write("only-feature.txt", "feature\n");
repo.commit("feature");
repo.git(&["checkout", "-q", "main"]);
repo.write("only-main.txt", "main\n");
repo.commit("main");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The agent's uncommitted edit comes along to a branch that doesn't contain the task's start.
repo.git(&["checkout", "-q", "feature"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"]);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n");
assert_eq!(repo.read("other.txt"), "feature\n");
assert_eq!(repo.read("only-feature.txt"), "feature\n");
assert!(!repo.dir.join("only-main.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
repo.write("b.txt", "agent\n");
// The user commits what the agent did to `a.txt` so far: it drops out of the task's changes.
repo.git(&["add", "a.txt"]);
repo.git(&["commit", "-q", "-m", "keep"]);
assert_eq!(changed(&mgr, &task.id).await, ["b.txt"]);
repo.write("a.txt", "agent\nagain\n");
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"]);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "back to the commit, not to before it");
assert!(!repo.dir.join("b.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
let repo = Checkout::new();
repo.write("mine.txt", "mine\n");
let (mgr, task) = repo.task().await;
repo.write("new.txt", "agent\n");
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"]);
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert!(!repo.dir.join("new.txt").exists());
assert_eq!(repo.read("mine.txt"), "mine\n");
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
use ide_core::settings::AgentSecuritySettings;
+9 -123
View File
@@ -117,92 +117,14 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
for s in repo.status()? {
if s.unstaged == Some(ChangeKind::Untracked) && !tracked.contains(&s.rel_path) {
out.push(added(s.path, s.rel_path));
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
}
}
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(out)
}
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 }
}
/// Where a task works and what its changes are measured against.
pub(crate) enum TaskBase {
/// A worktree of its own: everything since the commit it was created from, the agent's
/// own commits included.
Worktree { cwd: PathBuf, base: String },
/// The project's checkout, where the user works and commits too. `start` is the commit it
/// started from (`None` in a repository without commits, or an imported session).
Local { cwd: PathBuf, start: Option<String> },
}
impl TaskBase {
pub(crate) fn cwd(&self) -> &Path {
match self {
Self::Worktree { cwd, .. } | Self::Local { cwd, .. } => cwd,
}
}
/// The files the task changed, less those changed before it started and untouched since.
pub(crate) fn changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<Vec<AgentChange>> {
let (cwd, start) = match self {
Self::Worktree { cwd, base } => return Ok(without_untouched(changes(cwd, base)?, before)),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
// What is uncommitted, as the Commit tool window lists it: commits made since the task
// started drop out.
let head = repo.branch_info()?.head_commit;
let listed = match &head {
Some(head) => changes(cwd, head)?,
None => {
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));
all
}
};
let listed = without_untouched(listed, before);
let Some(start) = start.filter(|s| head.as_deref() != Some(*s)) else { return Ok(listed) };
// HEAD moved (a reset, a checkout, a pull): a file that is as it was when the task
// started is the user's doing, not the task's.
Ok(listed
.into_iter()
.filter(|c| {
before.contains_key(&c.rel_path)
|| !repo.file_bytes_at(&c.path.0, Some(start)).is_ok_and(|at| at == std::fs::read(&c.path.0).ok())
})
.collect())
}
/// 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.
pub(crate) fn rev(&self) -> IdeResult<Option<String>> {
let (cwd, start) = match self {
Self::Worktree { base, .. } => return Ok(Some(base.clone())),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(None) };
let head = repo.branch_info()?.head_commit;
// A start commit git no longer knows (garbage-collected) can't be gone back to.
let start = start.filter(|s| repo.merge_base(s, s).ok().flatten().is_some());
Ok(match (start, head) {
(Some(start), Some(head)) if start != head && repo.merge_base(start, &head)?.as_deref() != Some(start) => {
Some(start.to_owned())
}
(start, head) => head.or_else(|| start.map(str::to_owned)),
})
}
}
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
/// local checkout `root`, uncommitted: the user reviews and commits them with the
/// VCS tools. Files the checkout changed itself are left alone and reported, so
@@ -298,11 +220,11 @@ pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Pa
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `rev` (removed when `rev` lacks
/// it, or when there is no `rev`: the repository had no commits).
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
pub(crate) fn restore(
cwd: &Path,
rev: Option<&str>,
base: &str,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
@@ -320,13 +242,10 @@ pub(crate) fn restore(
})?;
Some(std::fs::read(copy)?)
}
None => match rev {
Some(rev) => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(rev))?
}
None => None,
},
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
};
match content {
Some(bytes) => {
@@ -571,39 +490,6 @@ mod tests {
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
}
#[test]
fn a_local_task_goes_back_to_its_start_unless_the_user_committed_on_top_of_it() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
let local = |start: Option<&str>| TaskBase::Local { cwd: repo.clone(), start: start.map(str::to_owned) };
let head = || head_of(&repo).unwrap().1;
let commit = |name: &str| {
std::fs::write(repo.join(name), "x\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", name]);
head_of(&repo).unwrap().1
};
assert_eq!(local(None).rev().unwrap(), None, "no commits yet");
let first = commit("a.txt");
assert_eq!(local(None).rev().unwrap(), Some(first.clone()), "an imported session: HEAD");
let start = commit("b.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "HEAD where it started");
let ahead = commit("c.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(ahead), "committed on top: HEAD");
git(&repo, &["reset", "-q", "--soft", &first]);
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "moved back: the start");
git(&repo, &["checkout", "-q", "-b", "side"]);
commit("d.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "another branch: the start");
let gone = "0123456789abcdef0123456789abcdef01234567";
assert_eq!(local(Some(gone)).rev().unwrap(), Some(head()), "an unknown start: HEAD");
let worktree = TaskBase::Worktree { cwd: repo.clone(), base: start.clone() };
assert_eq!(worktree.rev().unwrap(), Some(start));
}
#[test]
fn accept_leaves_files_the_checkout_changed_itself() {
let (_tmp, repo, created) = accept_repo();
+2 -10
View File
@@ -12,16 +12,11 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
pub fn to_ipc(p: &Path) -> String {
#[cfg(windows)]
{
let mut s = dunce::simplified(p).to_string_lossy().replace('\\', "/");
// The UI compares path strings; a drive letter is case-insensitive.
if s.as_bytes().get(1) == Some(&b':') {
s[..1].make_ascii_uppercase();
}
s
dunce::simplified(p).to_string_lossy().replace('\\', "/")
}
#[cfg(not(windows))]
{
@@ -149,8 +144,5 @@ mod tests {
assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b");
assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b");
assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#);
assert_eq!(to_ipc(Path::new(r"c:\a\b")), "C:/a/b");
assert_eq!(to_ipc(Path::new(r"\\?\d:\a")), "D:/a");
assert_eq!(to_ipc(Path::new(r"\\server\share\a")), "//server/share/a");
}
}
+2 -30
View File
@@ -108,32 +108,9 @@ impl Default for DeploymentSettings {
pub enum DeploymentType {
#[default]
Sftp,
Ftp,
/// FTP secured with `AUTH TLS` (explicit FTPS).
Ftps,
Local,
}
/// Host and login of an FTP / FTPS deployment server. The password is kept with
/// the saved passwords, under the server's id.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FtpConnection {
pub host: String,
/// `None`: 21.
pub port: Option<u16>,
/// Empty: anonymous login.
pub user: String,
/// Passive mode (the client opens data connections); off: active mode.
pub passive: bool,
}
impl Default for FtpConnection {
fn default() -> Self {
Self { host: String::new(), port: None, user: String::new(), passive: true }
}
}
/// A deployment filesystem and how project folders map onto it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -143,8 +120,6 @@ pub struct DeploymentServer {
pub name: String,
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// FTP / FTPS servers.
pub ftp: FtpConnection,
/// Absolute remote folder the mappings are relative to; empty = the login directory.
pub root_path: String,
/// Web server URL of the root (Open in Browser on the deployed file).
@@ -250,11 +225,8 @@ mod deployment_tests {
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
assert_eq!(legacy.kind, DeploymentType::Sftp);
assert_eq!(legacy.ssh_config, "ssh");
assert!(legacy.ftp.passive);
let ftp =
FtpConnection { host: "ftp.example.com".into(), port: Some(2121), user: "web".into(), passive: false };
for kind in [DeploymentType::Sftp, DeploymentType::Ftp, DeploymentType::Ftps, DeploymentType::Local] {
let server = DeploymentServer { kind, ftp: ftp.clone(), ..legacy.clone() };
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
let server = DeploymentServer { kind, ..legacy.clone() };
assert_eq!(
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
server
-12
View File
@@ -805,9 +805,6 @@ pub struct FileSettings {
pub file_types: BTreeMap<String, FileTypeAssociation>,
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
pub file_type_overrides: BTreeMap<String, String>,
/// File Types › Files opened in associated applications: file-name patterns
/// (`*.xlsx`) opened with the system application instead of an editor.
pub associated_applications: Vec<String>,
/// Compose detection choices, keyed by project-relative file path.
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
}
@@ -844,15 +841,6 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
associated_applications: vec![
"*.xlsx".into(),
"*.xls".into(),
"*.docx".into(),
"*.doc".into(),
"*.pptx".into(),
"*.ppt".into(),
"*.pdf".into(),
],
compose_file_overrides: BTreeMap::new(),
}
}
+2 -35
View File
@@ -138,11 +138,8 @@ impl WorkspaceEdit {
continue;
}
*expected = expected.or(version);
// Repeats within one entry are intended (LSP inserts at one position
// apply in order); only an alias entry repeats what is already merged.
let merged = own.len();
for edit in edits {
if !own[..merged].contains(&edit) {
if !own.contains(&edit) {
own.push(edit);
}
}
@@ -280,40 +277,10 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(test)]
#[cfg(all(test, unix))]
mod workspace_edit_tests {
use super::*;
#[test]
fn repeated_edits_within_one_entry_survive_but_repeats_across_entries_do_not() {
let path = PathBuf::from("missing-dir/main.ts");
let at = |line, text: &str| TextEdit {
range: Range::new(Pos::new(line, 0), Pos::new(line, 0)),
new_text: text.into(),
};
let entry = |edits: Vec<TextEdit>| FileEdit::Edit { path: path.clone().into(), edits, version: None };
let (nl, brace, other) = (at(0, "\n"), at(1, "}"), at(2, "x"));
for (entries, expected) in [
(vec![vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![brace.clone(), nl.clone(), brace.clone()]], vec![brace.clone(), nl.clone(), brace.clone()]),
(vec![vec![nl.clone(), nl.clone()], vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![nl.clone()], vec![nl.clone(), other.clone()]], vec![nl.clone(), other.clone()]),
(
vec![vec![nl.clone()], vec![other.clone(), other.clone()]],
vec![nl.clone(), other.clone(), other.clone()],
),
] {
let mut workspace =
WorkspaceEdit { files: entries.clone().into_iter().map(entry).collect(), ..Default::default() };
workspace.deduplicate();
assert_eq!(workspace.files, [entry(expected.clone())], "{entries:?}");
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once, "{entries:?}");
}
}
#[cfg(unix)]
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
+11 -7
View File
@@ -621,9 +621,9 @@ fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Valu
sent
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (in DAP mode a
/// temp-file name, `__debug_bin<digits>`; on Windows with `.exe` before the digits) and
/// removes it on a graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
/// has already exited or been killed and holds no file locks; a leftover of a
/// still-building run is removed on that run's own shutdown.
@@ -632,10 +632,14 @@ fn remove_delve_binaries(cwd: &Path) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// `__debug_bin`, then `.exe` on Windows, then DAP mode's temp-file digits.
// Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin").map(|s| s.strip_prefix(".exe").unwrap_or(s));
let leftover = suffix.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()));
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
// platforms keep the bare name. Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin");
let leftover = suffix.is_some_and(|s| {
s.is_empty()
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
+4 -5
View File
@@ -1472,7 +1472,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -2096,20 +2095,20 @@ async fn stop_kills_the_program_started_by_the_reported_launcher() {
fn delve_leftovers_are_removed_without_touching_other_files() {
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
std::fs::write(dir.join(name), b"bin").unwrap();
}
// Lookalikes that are not Delve's own binaries stay: sources, a future
// in-progress build, and a directory that happens to share the name.
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
std::fs::write(dir.join(name), b"keep").unwrap();
}
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
remove_delve_binaries(&dir);
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
-30
View File
@@ -169,25 +169,6 @@ pub async fn remote_rename(state: &Host, connection: FsConnection, from: String,
state.remote.remote_rename(&connection, &from, &to).await
}
/// Remote Host › Download to…: remote files and folders into a local folder, each under its
/// own name.
pub async fn remote_download_into(
window: &str,
state: &Host,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state.remote.download_into(&connection, &paths, &target, &token, &mut report).await;
done();
state.end_request(request);
r
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
@@ -216,17 +197,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_download_into", |c| async move {
remote_download_into(
&c.window,
&c.host,
c.arg("connection")?,
c.arg("paths")?,
c.arg("target")?,
c.arg("request")?,
)
.await
});
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
+1 -21
View File
@@ -19,17 +19,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
blocking(move || {
project.documents.open_virtual(
&vd.uri,
vd.language_id,
&vd.text,
&vd.encoding,
vd.has_bom,
vd.can_reload_encoding,
)
})
.await
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
@@ -40,8 +30,6 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state.
/// Virtual documents re-read their origin (archive, language server, Git blob)
/// instead of a disk file; converting them would write to it, so it stays refused.
pub async fn doc_set_encoding(
host: &Host,
window: &str,
@@ -50,14 +38,6 @@ pub async fn doc_set_encoding(
reload: bool,
) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
let vd = if uri.starts_with("git-revision:") {
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
} else {
project.lsp.reload_virtual(&uri, &encoding).await?
};
return host.blocking(move || project.documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom)).await;
}
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
}
+2 -5
View File
@@ -176,11 +176,8 @@ async fn binary_and_virtual_documents_are_skipped() {
std::fs::write(&path, b"\0\0binary payload").unwrap();
let binary = f.open(&path).await;
assert!(binary.read_only);
let virtual_doc = f
.project
.documents
.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual", "utf-8", false, false)
.unwrap();
let virtual_doc =
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
std::fs::write(&path, "now ordinary text").unwrap();
f.events.lock().clear();
f.sync().await;
+2 -45
View File
@@ -1,8 +1,8 @@
use super::Channel;
use crate::Host;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_core::{IdeResult, RequestId};
use ide_vfs::FileEntry;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
host.blocking(move || {
@@ -165,46 +165,3 @@ pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<St
})
.await
}
/// The file `window` may hand to the OS's default application: `path` itself, resolved, when it is a
/// file inside the window's project (its folder or a content root). The window must be trusted and
/// open locally; a remote window's files live on its own machine and are not launched here.
pub fn system_open_target(host: &Host, window: &str, path: PathBuf) -> IdeResult<PathBuf> {
host.require_trust(window)?;
let project = host.project(window)?;
let real =
dunce::canonicalize(&path).map_err(|_| IdeError::not_found(format!("{}: no such file", path.display())))?;
if !real.is_file() {
return Err(IdeError::invalid(format!("{}: not a file", path.display())));
}
let roots: Vec<PathBuf> = std::iter::once(project.root.clone())
.chain(project.content_roots())
.filter_map(|root| dunce::canonicalize(root).ok())
.collect();
if !inside_any_root(&real, &roots) {
return Err(IdeError::invalid(format!("{}: outside the project", path.display())));
}
Ok(real)
}
/// Whether `path` lies inside one of `roots`, comparing whole path components (`/a/bc` is not inside `/a/b`).
pub(crate) fn inside_any_root(path: &Path, roots: &[PathBuf]) -> bool {
roots.iter().any(|root| path.starts_with(root))
}
#[cfg(test)]
mod tests {
use super::inside_any_root;
use std::path::{Path, PathBuf};
#[test]
fn system_open_is_limited_to_paths_inside_a_project_root() {
let roots = vec![PathBuf::from("/work/app"), PathBuf::from("/work/shared")];
assert!(inside_any_root(Path::new("/work/app/docs/spec.pdf"), &roots));
assert!(inside_any_root(Path::new("/work/shared/a.docx"), &roots));
assert!(inside_any_root(Path::new("/work/app"), &roots));
assert!(!inside_any_root(Path::new("/work/app2/spec.pdf"), &roots));
assert!(!inside_any_root(Path::new("/home/me/.ssh/id_rsa"), &roots));
assert!(!inside_any_root(Path::new("/work/spec.pdf"), &[]));
}
}
+1 -1
View File
@@ -544,7 +544,7 @@ pub async fn lsp_goto_implementation(
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri, None).await;
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
+1 -59
View File
@@ -20,13 +20,10 @@ struct LogAccess {
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
/// `encoding` re-decodes the blob with it ("Reload with Encoding") instead of
/// the project's detection.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
encoding: Option<&str>,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
@@ -50,7 +47,7 @@ pub async fn revision_virtual_document(
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let (text, charset, has_bom) = revision_text(host, window, &path, &target.revision, encoding)
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
@@ -60,63 +57,8 @@ pub async fn revision_virtual_document(
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
encoding: charset.name().to_ascii_lowercase(),
has_bom,
can_reload_encoding: true,
})
}
/// Revision text with the charset it was decoded with and whether a matching
/// BOM was stripped; `None` when the file is absent at the revision.
async fn revision_text(
host: &Host,
window: &str,
path: &Path,
rev: &str,
encoding: Option<&str>,
) -> IdeResult<Option<(String, &'static encoding_rs::Encoding, bool)>> {
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()
});
let (path, rev, encoding) = (path.to_path_buf(), rev.to_owned(), encoding.map(str::to_owned));
blocking(move || {
fn with_repo<T>(
project: &Option<std::sync::Arc<Project>>,
external: &Option<std::sync::Arc<ide_vcs::Repo>>,
path: &Path,
f: impl FnOnce(&ide_vcs::Repo) -> IdeResult<T>,
) -> IdeResult<T> {
match project {
Some(project) => project.with_log_repo_of(path, f),
None => f(external
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?),
}
}
let (text_encoding, charset) = match &encoding {
Some(label) => {
let charset = encoding_rs::Encoding::for_label(label.trim().as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid(format!("unknown encoding {label:?}")))?;
(ide_vcs::TextEncoding::from(charset), charset)
}
None => match &project {
Some(project) => {
let text_encoding = project.vcs_encoding(&path, Some(&rev))?;
(text_encoding, text_encoding.charset)
}
None => (ide_vcs::TextEncoding::from(encoding_rs::UTF_8), encoding_rs::UTF_8),
},
};
let bytes = with_repo(&project, &external, &path, |r| r.file_bytes_at(&path, Some(&rev)))?;
let Some(bytes) = bytes else { return Ok(None) };
let has_bom = encoding_rs::Encoding::for_bom(&bytes).is_some_and(|(enc, _)| enc == charset);
let text =
with_repo(&project, &external, &path, |r| r.file_at(&path, Some(&rev), text_encoding))?.unwrap_or_default();
Ok(Some((text, charset, has_bom)))
})
.await
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
+1 -1
View File
@@ -1231,7 +1231,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
/// The roots a file may navigate into: its own root (the workspace member containing it, or
/// the project root) plus that root's configured dependencies. A workspace member depends
/// only on the roots picked per member in Project Structure › Dependencies —
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
/// being attached to the workspace never makes its code navigable by itself.
/// Cached navigation roots per (project, file), with the instant they were computed.
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
+2 -8
View File
@@ -93,15 +93,9 @@ async fn external_history_is_readable_and_never_becomes_a_write_target() {
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let uri = format!("git-revision:{hex}/deleted.txt");
let document = vcs::revision_virtual_document(&host, "standalone", &uri, None).await.unwrap();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
assert_eq!(document.text, "historical content\n");
assert_eq!(document.encoding, "utf-8");
assert!(document.can_reload_encoding);
// Reload with Encoding re-decodes the blob with the chosen charset.
let reloaded = vcs::revision_virtual_document(&host, "standalone", &uri, Some("utf-16le")).await.unwrap();
assert_eq!(reloaded.encoding, "utf-16le");
assert_ne!(reloaded.text, document.text);
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
-1
View File
@@ -330,7 +330,6 @@ async fn local_deployment_runs_on_the_project_host() {
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"db_sources",
"db_save_sources",
"db_set_password",
-1
View File
@@ -94,7 +94,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
-16
View File
@@ -55,10 +55,6 @@ pub struct LanguageConfig {
/// the first one whose root marker is found applies.
#[serde(default)]
pub framework_ui_modes: Vec<FrameworkUiMode>,
/// Files of this language in a framework's projects that their extension alone would
/// give to another language (Django templates are `.html` files in `templates`).
#[serde(default)]
pub framework_files: Vec<FrameworkFiles>,
#[serde(default)]
pub tree_sitter: Option<String>,
#[serde(default)]
@@ -94,18 +90,6 @@ pub struct FrameworkUiMode {
pub root_markers: Vec<String>,
}
/// Files in a framework's folder: a file with one of `extensions` below a folder named
/// `folder`, which has a root marker in a directory above it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FrameworkFiles {
pub folder: String,
/// Without the dot, compared ignoring case.
pub extensions: Vec<String>,
/// Matched like language servers' `root-markers`, e.g. `manage.py`.
pub root_markers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
pub struct IndentConfig {
+7 -55
View File
@@ -22,7 +22,6 @@ const BUILTIN: &[(&str, &str)] = &[
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("django-html", include_str!("../../../languages/django-html.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
("dotenv", include_str!("../../../languages/dotenv.toml")),
("elixir", include_str!("../../../languages/elixir.toml")),
@@ -372,13 +371,14 @@ impl LanguageRegistry {
.map(|f| f.ui_mode.clone())
}
/// Creating or removing `path` can change a file's `framework_ui_mode` or its
/// language (`framework-files`): it is a framework's root marker.
/// Creating or removing `path` can change a file's `framework_ui_mode`:
/// it is a framework's root marker.
pub fn is_framework_marker(&self, path: &Path) -> bool {
let state = self.state();
let modes = state.languages.iter().flat_map(|l| &l.framework_ui_modes).map(|f| &f.root_markers);
let files = state.languages.iter().flat_map(|l| &l.framework_files).map(|f| &f.root_markers);
modes.chain(files).flatten().any(|m| crate::markers::is_marker_path(path, m))
self.state()
.languages
.iter()
.flat_map(|l| &l.framework_ui_modes)
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
}
/// Detects by Override File Type, exact file name, then file-name pattern
@@ -400,9 +400,6 @@ impl LanguageRegistry {
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
return languages[*i].id.clone();
}
if let Some(l) = languages.iter().find(|l| l.framework_files.iter().any(|f| is_framework_file(f, path))) {
return l.id.clone();
}
let lower = file_name.to_ascii_lowercase();
let mut rest = lower.as_str();
while let Some(dot) = rest.find('.') {
@@ -423,19 +420,6 @@ impl LanguageRegistry {
}
}
/// `path` is one of `files`: its extension is listed, and the nearest folder above it named
/// `files.folder` has a root marker in a directory above that folder.
fn is_framework_file(files: &crate::FrameworkFiles, path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false };
if !files.extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
return false;
}
path.ancestors()
.skip(1)
.find(|dir| dir.file_name().is_some_and(|name| name == files.folder.as_str()))
.is_some_and(|folder| crate::marked_root(None, folder, &files.root_markers).is_some())
}
/// `*` matches any (possibly empty) run of characters, `?` exactly one; everything else is literal.
fn wildcard_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
@@ -513,7 +497,6 @@ mod tests {
"Brainfuck",
"Cobol",
"C#",
"Django Template",
"Clojure",
"ClojureScript",
"Closure Stylesheets (GSS)",
@@ -668,7 +651,6 @@ mod tests {
("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]),
("typescript", vec!["vtsls", "typescript-language-server"]),
("java", vec!["jdtls", "kmp-lsp"]),
("kotlin", vec!["kotlin-lsp", "kotlin-language-server", "kmp-lsp"]),
] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let got: Vec<(&str, bool)> = cfg
@@ -819,36 +801,6 @@ mod tests {
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
#[test]
fn django_projects_make_html_in_templates_folders_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("src/orders/templates/orders/email")).unwrap();
std::fs::create_dir_all(root.join("site/templates")).unwrap();
std::fs::write(root.join("src/manage.py"), "").unwrap();
assert_eq!(detect("src/orders/templates/orders/detail.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/email/body.TXT"), "django-html");
assert_eq!(detect("src/templates/base.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/data.json"), "json");
assert_eq!(detect("src/orders/static/orders/page.html"), "html");
// No `manage.py` above this `templates` folder.
assert_eq!(detect("site/templates/index.html"), "html");
assert_eq!(detect("page.djhtml"), "django-html");
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/django-app");
let detect = |file: &str| reg.detect(&fixture.join(file), None).as_str().to_owned();
assert_eq!(detect("shop/templates/shop/product_list.html"), "django-html");
assert_eq!(detect("shop/templates/shop/email/order.txt"), "django-html");
assert_eq!(detect("pages/promo.html"), "html");
}
#[test]
fn markers_outside_the_project_do_not_make_angular_templates() {
let reg = reg();
-1
View File
@@ -29,5 +29,4 @@ zip.workspace = true
[dev-dependencies]
tempfile.workspace = true
encoding_rs.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
-223
View File
@@ -1,223 +0,0 @@
//! go.mod as GoLand shows it: available upgrades, vulnerabilities and deprecated modules are
//! reported as soon as the file is opened. gopls reports upgrades only from its "Check for
//! upgrades" code lens, so that runs once per go.mod and session; its results (and the
//! vulnerabilities from the `vulncheck` setting) come back as gopls diagnostics with quick fixes.
//! gopls never reports a deprecated module, so `go list -m -u` does, on open and on save.
use crate::LspManager;
use crate::server::Server;
use ide_core::{DocId, Pos, Range};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
pub(crate) const CHECK_UPGRADES: &str = "gopls.check_upgrades";
/// gopls answers code lenses once it has loaded the module.
const ATTEMPTS: u32 = 10;
const RETRY: Duration = Duration::from_secs(2);
/// Producer of the deprecation diagnostics (one full set per go.mod).
const DEPRECATIONS: &str = "go-mod-deprecations";
fn is_go_mod(path: &Path) -> bool {
path.file_name().is_some_and(|n| n == "go.mod")
}
impl LspManager {
pub(crate) async fn check_go_mod(&self, doc: DocId, path: &Path, server: Arc<Server>) {
if server.name != "gopls" || !is_go_mod(path) {
return;
}
self.report_deprecated_modules(doc, &server).await;
if !self.go_mod_checked.lock().insert(path.to_owned()) {
return;
}
for attempt in 0..ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(RETRY).await;
}
if !self.docs.lock().contains_key(&doc) {
break;
}
let Ok(lenses) = self.code_lens(doc).await else { continue };
let Some(command) = lenses.into_iter().filter_map(|l| l.command).find(|c| c.command == CHECK_UPGRADES)
else {
continue;
};
if let Err(e) = self.execute_command(doc, command).await {
tracing::debug!("gopls: {CHECK_UPGRADES} for {}: {}", path.display(), e.message);
}
return;
}
// Not checked after all (closed, or gopls never offered the lens): a later open retries.
self.go_mod_checked.lock().remove(path);
}
/// After a save of a go.mod: its requirements may have changed.
pub(crate) fn go_mod_saved(&self, doc: DocId) {
let server = {
let docs = self.docs.lock();
let Some(d) = docs.get(&doc).filter(|d| is_go_mod(&d.path)) else { return };
let Some(a) = d.servers.iter().find(|a| a.server.name == "gopls") else { return };
a.server.clone()
};
let Some(me) = self.me.upgrade() else { return };
self.runtime.spawn(async move { me.report_deprecated_modules(doc, &server).await });
}
async fn report_deprecated_modules(&self, doc: DocId, server: &Server) {
let Some((path, text)) = self.docs.lock().get(&doc).map(|d| (d.path.clone(), d.text.as_str().to_owned()))
else {
return;
};
let required = required_modules(&text);
if required.is_empty() {
return;
}
let config = server.effective_config();
let search = crate::launch::server_search_path(&config);
let Some(dir) = path.parent().map(Path::to_path_buf) else { return };
let modules: Vec<String> = required.iter().map(|r| r.path.clone()).collect();
let listed = tokio::task::spawn_blocking(move || list_deprecations(&dir, &search, &modules)).await;
let Ok(Some(deprecated)) = listed else { return };
// The document changed meanwhile: its own save runs again.
if self.docs.lock().get(&doc).is_none_or(|d| d.text.as_str() != text) {
return;
}
let items = required
.into_iter()
.filter_map(|r| {
let message = deprecated.iter().find(|(p, _)| *p == r.path)?.1.trim();
Some(ide_core::Diagnostic {
range: r.range,
severity: ide_core::Severity::Warning,
message: format!("{} is deprecated: {message}", r.path),
source: Some("go".into()),
code: None,
unnecessary: false,
deprecated: true,
server: Some("gopls".into()),
})
})
.collect();
self.sink().emit(ide_core::IdeEvent::Diagnostics { path: path.into(), source: DEPRECATIONS.into(), items });
}
}
#[derive(Debug, PartialEq)]
struct Required {
path: String,
/// The module path in go.mod (UTF-16 columns).
range: Range,
}
/// Modules of go.mod's `require` directives, single-line or in a block.
fn required_modules(text: &str) -> Vec<Required> {
let mut out = Vec::new();
let mut in_block = false;
for (line, raw) in text.lines().enumerate() {
let code = raw.split("//").next().unwrap_or("");
let trimmed = code.trim_start();
let entry = if in_block {
if trimmed.starts_with(')') {
in_block = false;
continue;
}
trimmed
} else if let Some(rest) = trimmed.strip_prefix("require").filter(|r| r.starts_with(char::is_whitespace)) {
let rest = rest.trim_start();
if rest.starts_with('(') {
in_block = true;
continue;
}
rest
} else {
continue;
};
let mut words = entry.split_whitespace();
let (Some(module), Some(_version)) = (words.next(), words.next()) else { continue };
let module = module.trim_matches('"');
let Some(start) = code.find(module) else { continue };
let col = |byte: usize| code[..byte].encode_utf16().count() as u32;
let line = line as u32;
out.push(Required {
path: module.to_owned(),
range: Range { start: Pos { line, col: col(start) }, end: Pos { line, col: col(start + module.len()) } },
});
}
out
}
#[derive(Deserialize)]
struct ListedModule {
#[serde(rename = "Path")]
path: String,
#[serde(rename = "Deprecated", default)]
deprecated: Option<String>,
}
/// `(module, message)` of each deprecated module in `modules`; `None` when `go` failed.
fn list_deprecations(dir: &Path, search: &std::ffi::OsStr, modules: &[String]) -> Option<Vec<(String, String)>> {
let go: PathBuf = ide_core::tool_path::find_command("go", search, dir)?;
let output = ide_core::process::prepare(&mut std::process::Command::new(go))
.args(["list", "-m", "-u", "-e", "-json"])
.args(modules)
.current_dir(dir)
.env("PATH", search)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.stdout.is_empty() {
tracing::debug!("go list -m -u in {}: {}", dir.display(), output.status);
return None;
}
Some(parse_deprecations(&output.stdout))
}
fn parse_deprecations(json: &[u8]) -> Vec<(String, String)> {
serde_json::Deserializer::from_slice(json)
.into_iter::<ListedModule>()
.map_while(Result::ok)
.filter_map(|m| Some((m.path, m.deprecated.filter(|d| !d.trim().is_empty())?)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn at(line: u32, from: u32, to: u32) -> Range {
Range { start: Pos { line, col: from }, end: Pos { line, col: to } }
}
#[test]
fn finds_required_modules_in_blocks_and_single_lines() {
let text = "module example.com/m\n\ngo 1.24\n\nrequire github.com/a/b v1.0.0 // indirect\n\
require (\n\tgithub.com/c/d v1.2.0\n\t\"github.com/e/é\" v0.1.0 // indirect\n)\n\
// require github.com/x/y v1.0.0\nreplace github.com/c/d => ../d\ntool golang.org/x/tools/cmd/stringer\n";
assert_eq!(
required_modules(text),
vec![
Required { path: "github.com/a/b".into(), range: at(4, 8, 22) },
Required { path: "github.com/c/d".into(), range: at(6, 1, 15) },
Required { path: "github.com/e/é".into(), range: at(7, 2, 16) },
]
);
}
#[test]
fn reads_deprecations_from_the_go_list_stream() {
let json = br#"{"Path": "github.com/golang/protobuf", "Version": "v1.5.4",
"Deprecated": "Use the \"google.golang.org/protobuf\" module instead."}
{"Path": "github.com/sergi/go-diff", "Version": "v1.3.1"}
{"Path": "example.com/blank", "Deprecated": " "}"#;
assert_eq!(
parse_deprecations(json),
vec![(
"github.com/golang/protobuf".to_owned(),
"Use the \"google.golang.org/protobuf\" module instead.".to_owned()
)]
);
}
}
-39
View File
@@ -361,19 +361,6 @@ impl ClientHandler {
}
}
/// Shown once per session when tsserver keeps aborting at its heap limit; the UI adds a
/// button that opens the language server settings.
const TSSERVER_OUT_OF_MEMORY: &str = "tsserver stopped because it reached its memory limit and is restarted, so code intelligence may pause or miss results. Raise typescript.tsserver.maxTsServerMemory (in MB) in the server's Workspace Configuration.";
/// Whether a vtsls log line reports tsserver aborting, which is how Node exits at its heap
/// limit: exit code 134 on Windows, `SIGABRT` elsewhere.
fn tsserver_ran_out_of_memory(text: &str) -> bool {
text.split_once("TSServer exited. ").is_some_and(|(_, rest)| {
let rest = rest.trim_end();
rest.starts_with("Code: 134.") || rest.ends_with("Signal: SIGABRT")
})
}
impl Handler for ClientHandler {
fn on_notification(&self, method: &str, params: Value) {
let Some(mgr) = self.mgr.upgrade() else { return };
@@ -388,16 +375,6 @@ impl Handler for ClientHandler {
"window/showMessage" => self.show_message(&mgr, &params),
"window/logMessage" => {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
tracing::warn!("[{}] {text}", self.server.name);
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
mgr.sink().emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "TypeScript server ran out of memory".into(),
body: Some(TSSERVER_OUT_OF_MEMORY.into()),
});
}
}
if self.server.name == "jdtls"
&& text.contains("Can't use Java ")
&& text.contains(" to import Gradle project ")
@@ -541,19 +518,3 @@ fn merge(base: &mut Value, layer: Value) {
(base, layer) => *base = layer,
}
}
#[cfg(test)]
mod tests {
use super::tsserver_ran_out_of_memory;
#[test]
fn tsserver_aborts_are_out_of_memory() {
assert!(tsserver_ran_out_of_memory("TSServer exited. Code: 134. Signal: null"));
assert!(tsserver_ran_out_of_memory("TSServer exited. Code: null. Signal: SIGABRT"));
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: 0. Signal: null"));
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: null. Signal: SIGTERM"));
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: 1340. Signal: null"));
assert!(!tsserver_ran_out_of_memory("Code: 134."));
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub(crate) const SERVER: &str = "jdtls";
const SERVER: &str = "jdtls";
pub(crate) mod configuration;
mod formatter;
+1 -1
View File
@@ -146,7 +146,7 @@ fn required_env_set(config: &LanguageServerConfig) -> bool {
}
/// A configured `PATH` (e.g. a selected toolchain prepended) also resolves the binary.
pub(crate) fn server_search_path(config: &LanguageServerConfig) -> OsString {
fn server_search_path(config: &LanguageServerConfig) -> OsString {
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
}
+26 -234
View File
@@ -15,7 +15,6 @@ mod convert;
mod external_project;
mod file_ops;
mod file_watch;
mod go_mod;
mod go_scope;
mod handler;
mod injections;
@@ -222,8 +221,6 @@ pub struct LspManager {
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
/// go.mod files whose upgrades gopls has been asked to check this session.
go_mod_checked: Mutex<HashSet<PathBuf>>,
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
/// Contents fetched for virtual URIs.
@@ -329,41 +326,6 @@ const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis
/// Implementation requests of one line-marker pass in flight at once.
const IMPLEMENTATION_CONCURRENCY: usize = 4;
/// How a virtual document's content was decoded, and whether it can be
/// re-decoded ("Reload with Encoding").
struct Decoding {
encoding: String,
has_bom: bool,
can_reload: bool,
}
impl Decoding {
/// Text the server decoded itself: no raw bytes here to re-read.
fn server_text() -> Self {
Decoding { encoding: "utf-8".into(), has_bom: false, can_reload: false }
}
}
/// jdtls' source attachment of a `jdt:` class (`java.project.resolveSourceAttachment`).
struct SourceAttachment {
path: Option<String>,
encoding: Option<String>,
can_edit_encoding: bool,
}
/// Reads a `jar:` document's source: an entry as is, a class from the sources
/// jar next to its binary. `encoding` decodes with it instead of detecting.
fn read_jar_source(uri: &str, encoding: Option<&str>) -> Option<(String, ide_vfs::LoadedText)> {
match library_source::parse(uri) {
Some(e) if !e.entry.ends_with(".class") => {
library_source::read_entry(&e.jar, &e.entry, encoding).map(|loaded| (e.entry, loaded))
}
Some(_) => library_source::class_source(uri, encoding)
.and_then(|(source, loaded)| Some((library_source::parse(&source)?.entry, loaded))),
None => None,
}
}
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
/// in the artifact's sources jar when it is cached, at the declaration of the
/// name navigated from; [`LspManager::virtual_document`] serves it.
@@ -378,9 +340,8 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
return loc;
};
let Some((source, loaded)) = library_source::class_source(uri, None) else { return loc };
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
let Some((source, text)) = library_source::class_source(uri) else { return loc };
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
Location { path: source.clone().into(), range, uri: Some(source) }
})
.collect()
@@ -472,7 +433,6 @@ impl LspManager {
debug_servers: Mutex::new(HashMap::new()),
configuration_cache: Arc::new(Mutex::new(Default::default())),
shown_messages: Mutex::new(HashSet::new()),
go_mod_checked: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
injections: injections::State::default(),
@@ -1072,7 +1032,6 @@ impl LspManager {
servers: Vec<Arc<Server>>,
) {
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
let canonical_path = canonical.clone();
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
let mut state = DocState {
path,
@@ -1103,13 +1062,6 @@ impl LspManager {
self.schedule_pull(doc, None, Duration::ZERO);
for s in &servers {
self.ensure_started(s);
// Waits for a server still starting, so it is not tied to `running()` below.
if s.name == "gopls"
&& let Some(me) = self.me.upgrade()
{
let (path, server) = (canonical_path.clone(), s.clone());
self.runtime.spawn(async move { me.check_go_mod(doc, &path, server).await });
}
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
@@ -1251,7 +1203,6 @@ impl LspManager {
drop(docs);
self.schedule_pull(doc, None, Duration::ZERO);
self.pull_dependents(doc);
self.go_mod_saved(doc);
}
pub fn did_close(&self, doc: DocId) {
@@ -3069,9 +3020,8 @@ impl LspManager {
let original = uri.to_owned();
let archive = sources.clone();
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&original, &archive, None).map(|(uri, loaded)| {
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
library_source::source_in_archive(&original, &archive).map(|(uri, text)| {
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
Location { path: uri.clone().into(), uri: Some(uri), range }
})
})
@@ -3113,10 +3063,28 @@ impl LspManager {
if scheme == "jar" {
let found = {
let uri = uri.to_owned();
tokio::task::spawn_blocking(move || read_jar_source(&uri, None)).await.ok().flatten()
tokio::task::spawn_blocking(move || match library_source::parse(&uri) {
Some(e) if !e.entry.ends_with(".class") => {
library_source::read_entry(&e.jar, &e.entry).map(|text| (e.entry, text))
}
Some(_) => library_source::class_source(&uri)
.and_then(|(source, text)| Some((library_source::parse(&source)?.entry, text))),
None => None,
})
.await
.ok()
.flatten()
};
if let Some((entry, loaded)) = found {
return Ok(self.store_jar_entry(uri, entry, loaded));
if let Some((entry, text)) = found {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
return Ok(self.store_virtual(
uri,
text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
));
}
}
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
@@ -3187,21 +3155,7 @@ impl LspManager {
continue;
}
};
// jdtls decodes attached sources itself: its attachment encoding is
// the document's real charset, and editable ones can be re-decoded.
let decoding = if scheme == "jdt" && t.server.name == jdtls::SERVER && !virtual_doc::is_decompiled(&text) {
match self.jdt_source_attachment(uri).await {
Some(att) if att.path.is_some() => Decoding {
encoding: att.encoding.unwrap_or_else(|| "utf-8".into()),
has_bom: false,
can_reload: att.can_edit_encoding,
},
_ => Decoding::server_text(),
}
} else {
Decoding::server_text()
};
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id), decoding));
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
}
if jar_class && error.is_none() {
return Err(IdeError::not_found(format!(
@@ -3213,7 +3167,6 @@ impl LspManager {
}
/// Caches `text` as the virtual document `uri`, titled `title` (else after the URI).
#[allow(clippy::too_many_arguments)]
fn store_virtual(
&self,
uri: &str,
@@ -3222,7 +3175,6 @@ impl LspManager {
description: Option<String>,
languages: &[LanguageId],
server: Option<ide_core::ServerId>,
decoding: Decoding,
) -> VirtualDocument {
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
virtual_doc::is_csharp_decompiled(uri)
@@ -3243,9 +3195,6 @@ impl LspManager {
title,
description: description.or_else(|| virtual_doc::description(uri)),
decompiled,
encoding: decoding.encoding,
has_bom: decoding.has_bom,
can_reload_encoding: decoding.can_reload,
};
if let Some(server) = server {
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
@@ -3258,163 +3207,6 @@ impl LspManager {
doc
}
/// Caches a decoded `jar:` entry under the document's original URI, titled
/// after the entry; the backend owns these bytes, so they can be re-decoded.
fn store_jar_entry(&self, uri: &str, entry: String, loaded: ide_vfs::LoadedText) -> VirtualDocument {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
self.store_virtual(
uri,
loaded.text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
)
}
/// The source attachment jdtls resolves for a `jdt:` class; `None` when the
/// command is unavailable or the class's container cannot be queried.
async fn jdt_source_attachment(&self, uri: &str) -> Option<SourceAttachment> {
let result = self
.jdtls_command(
&self.project_root,
"java.project.resolveSourceAttachment",
vec![json!({ "classFileUri": uri })],
)
.await
.ok()?;
if result.get("errorMessage").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
return None;
}
let attrs = result.get("attributes")?;
let non_empty = |key: &str| attrs.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_owned);
Some(SourceAttachment {
path: non_empty("sourceAttachmentPath"),
encoding: non_empty("sourceAttachmentEncoding"),
can_edit_encoding: attrs.get("canEditEncoding").and_then(Value::as_bool).unwrap_or(true),
})
}
/// Updates a `jdt:` class's source attachment encoding. The command drops
/// the attachment when the path is omitted, so the resolved path is always
/// sent along with the encoding.
async fn update_jdt_source_encoding(&self, uri: &str, path: &str, encoding: &str) -> IdeResult<()> {
let result = self
.jdtls_command(
&self.project_root,
"java.project.updateSourceAttachment",
vec![json!({
"classFileUri": uri,
"attributes": { "sourceAttachmentPath": path, "sourceAttachmentEncoding": encoding },
})],
)
.await?;
if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) {
return Err(IdeError::unavailable(error));
}
Ok(())
}
/// Waits until the resolved attachment reports `encoding`; `false` once
/// `until` passes. Maven applies attachment updates in a background job,
/// so the new encoding is not visible immediately after the update.
async fn wait_for_jdt_source_encoding(&self, uri: &str, encoding: &str, until: tokio::time::Instant) -> bool {
loop {
match self.jdt_source_attachment(uri).await {
Some(att) if att.encoding.as_deref() == Some(encoding) => return true,
_ if tokio::time::Instant::now() < until => {
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
_ => return false,
}
}
}
/// "Reload with Encoding" for a virtual document: re-reads its origin with
/// `encoding` and replaces the cached content. `jar:` entries are re-read
/// here; `jdt:` sources are decoded by jdtls, so its source attachment
/// encoding is updated and the contents fetched again (keeping the server's
/// text and positions in sync). Server-generated content cannot be re-decoded.
pub async fn reload_virtual(&self, uri: &str, encoding: &str) -> IdeResult<VirtualDocument> {
ide_vfs::encode("", encoding, false)?;
match virtual_doc::scheme(uri) {
"jar" => {
let (owned_uri, owned_encoding) = (uri.to_owned(), encoding.to_owned());
let found = tokio::task::spawn_blocking(move || read_jar_source(&owned_uri, Some(&owned_encoding)))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
let Some((entry, loaded)) = found else {
return Err(IdeError::not_found(format!(
"no source is available for {}",
virtual_doc::title(uri, false)
)));
};
Ok(self.store_jar_entry(uri, entry, loaded))
}
"jdt" if self.origin_server(uri).is_none_or(|s| s.name == jdtls::SERVER) => {
let Some(att) = self.jdt_source_attachment(uri).await else {
return Err(IdeError::unavailable("jdtls cannot resolve the source attachment of this class"));
};
let Some(path) = att.path else {
return Err(IdeError::invalid("this class has no attached sources to re-decode"));
};
if !att.can_edit_encoding {
return Err(IdeError::invalid("the source attachment encoding of this library cannot be changed"));
}
self.update_jdt_source_encoding(uri, &path, encoding).await?;
// Maven containers apply the update in a background job (persist
// attached sources, then refresh the classpath): wait until the
// resolved attachment reports the new encoding, else the refetch
// below would still decode with the old one.
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
let first_try = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
if !self.wait_for_jdt_source_encoding(uri, encoding, first_try).await {
// jdtls matches the existing encoding attribute by reference
// (`==`), so an entry restored from the saved container state
// gains a duplicate attribute and keeps the old value. That
// update still refreshes the container, after which a second
// update replaces the value correctly.
self.update_jdt_source_encoding(uri, &path, encoding).await?;
if !self.wait_for_jdt_source_encoding(uri, encoding, deadline).await {
return Err(IdeError::unavailable(
"jdtls did not apply the source attachment encoding; try reloading again",
));
}
}
// JDT keeps the class file's decoded buffer cached, so
// classFileContents would still return the old decoding: re-read
// the attached source entry here, like `jar:` documents. The
// server's open document is kept in sync by the regular
// document change flow.
let (owned_uri, owned_path, owned_encoding) = (uri.to_owned(), path.clone(), encoding.to_owned());
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&owned_uri, Path::new(&owned_path), Some(&owned_encoding))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if let Some((_source_uri, loaded)) = found {
return Ok(self.store_virtual(
uri,
loaded.text,
None,
None,
&[],
self.origin_server(uri).map(|s| s.id),
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
));
}
// Attachments that are not readable archives (e.g. folders):
// fall back to the server's contents.
self.virtual_docs.lock().remove(uri);
self.virtual_document(uri).await
}
_ => Err(IdeError::invalid(
"this document's content is generated by a language server and cannot be re-decoded with another encoding",
)),
}
}
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
/// so edits flow through `ide-workspace::Documents`.
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
+33 -119
View File
@@ -7,8 +7,8 @@
//! it in the Maven or Gradle cache.
use ide_core::{Pos, Range};
use ide_vfs::LoadedText;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use std::io::Read;
use std::path::{Path, PathBuf};
/// An entry of an archive named by a `jar:` URI.
@@ -57,32 +57,29 @@ impl JarEntry {
}
}
/// The text of `entry` in the archive `jar`, decoded like a file opened in the
/// editor; `encoding` decodes with it instead of detecting ("Reload with
/// Encoding"). `None` for binary or unreadable entries.
pub(crate) fn read_entry(jar: &Path, entry: &str, encoding: Option<&str>) -> Option<LoadedText> {
let path = ide_vfs::archive_entry_path(jar, entry);
let loaded = match encoding {
Some(enc) => ide_vfs::load_text_as(&path, enc).ok()?,
None => ide_vfs::load_text(&path).ok()?,
};
(!loaded.binary).then_some(loaded)
/// The text of `entry` in the archive `jar`, when it is a UTF-8 file.
pub(crate) fn read_entry(jar: &Path, entry: &str) -> Option<String> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
let mut file = zip.by_name(entry).ok()?;
let mut text = String::new();
file.read_to_string(&mut text).ok()?;
Some(text)
}
/// The library source for a class entry: its `.kt` / `.java` file in the
/// artifact's sources jar, as `(uri, text)`.
pub(crate) fn class_source(uri: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
let jar = parse(uri)?;
let class = jar.entry.strip_suffix(".class")?;
for sources in sources_jars(&jar.jar) {
if let Some((entry, text)) = source_entry(&sources, class, encoding) {
if let Some((entry, text)) = source_entry(&sources, class) {
return Some((jar.uri_of(&sources, &entry), text));
}
}
None
}
pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str>) -> Option<(String, LoadedText)> {
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
let jar = if uri.starts_with("jdt://contents/") {
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
let parts: Vec<_> = path.split('/').collect();
@@ -99,7 +96,7 @@ pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str
parse(uri)?
};
let class = jar.entry.strip_suffix(".class")?;
let (entry, text) = source_entry(sources, class, encoding)?;
let (entry, text) = source_entry(sources, class)?;
Some((jar.uri_of(sources, &entry), text))
}
@@ -120,8 +117,8 @@ fn sources_jars(jar: &Path) -> Vec<PathBuf> {
}
/// The source file of `class` (`pkg/Name`, `pkg/Outer$Inner`, `pkg/FileKt`) in `sources`.
fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
let (package, name) = class.rsplit_once('/').unwrap_or(("", class));
let outer = name.split('$').next().unwrap_or(name);
let dir = if package.is_empty() { String::new() } else { format!("{package}/") };
@@ -130,9 +127,13 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
candidates.push(format!("{dir}{file}.kt"));
}
let read = |entry: &str| read_entry(sources, entry, encoding);
let read = |zip: &mut zip::ZipArchive<std::fs::File>, entry: &str| {
let mut text = String::new();
zip.by_name(entry).ok()?.read_to_string(&mut text).ok()?;
Some(text)
};
for entry in &candidates {
if let Some(text) = read(entry) {
if let Some(text) = read(&mut zip, entry) {
return Some((entry.clone(), text));
}
}
@@ -144,8 +145,8 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
.collect();
let jvm_name = format!("@file:JvmName(\"{outer}\")");
for entry in kotlin_files {
if let Some(text) = read(&entry)
&& text.text.contains(&jvm_name)
if let Some(text) = read(&mut zip, &entry)
&& text.contains(&jvm_name)
{
return Some((entry, text));
}
@@ -232,15 +233,11 @@ mod tests {
use std::io::Write;
fn jar(path: &Path, entries: &[(&str, &str)]) {
jar_bytes(path, &entries.iter().map(|(n, t)| (*n, t.as_bytes())).collect::<Vec<_>>());
}
fn jar_bytes(path: &Path, entries: &[(&str, &[u8])]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, bytes) in entries {
for (name, text) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
@@ -277,12 +274,12 @@ mod tests {
"jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path",
"jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class",
] {
let (uri, text) = source_in_archive(uri, &sources, None).unwrap();
let (uri, text) = source_in_archive(uri, &sources).unwrap();
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
assert!(text.text.contains("class Lib"));
assert!(text.contains("class Lib"));
}
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources, None).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources, None).is_none());
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none());
}
#[test]
@@ -315,103 +312,20 @@ mod tests {
("org/springframework/boot/Builder.class", "Builder.kt"),
("Root.class", "Root.java"),
] {
let (source_uri, text) =
class_source(&uri(class), None).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let (source_uri, text) = class_source(&uri(class)).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let dir = class.rsplit_once('/').map_or(String::new(), |(p, _)| format!("{p}/"));
assert!(source_uri.ends_with(&format!("-sources.jar!/{dir}{file}")), "{source_uri}");
assert_eq!(
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry, None)).map(|l| l.text),
Some(text.text.clone())
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(),
Some(text.as_str())
);
}
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None);
}
// No sources jar: nothing to show from here.
let lone = tmp.path().join("lone/x-1.jar");
jar(&lone, &[]);
assert!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/")), None).is_none());
}
/// The reported case: a library source file saved as GBK bytes, not UTF-8.
const GBK_SAMPLE: &str = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {\n public static String message() {\n return \"中文编码测试:是否启用\";\n }\n}\n";
#[test]
fn archive_entries_decode_like_files_opened_in_the_editor() {
let tmp = tempfile::tempdir().unwrap();
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
// encoding_rs' encode() falls back to UTF-8 for UTF-16 (WHATWG has no encoder for it).
let mut utf16 = vec![0xFF, 0xFE];
utf16.extend(GBK_SAMPLE.encode_utf16().flat_map(u16::to_le_bytes));
let cases: Vec<(&str, Vec<u8>, &str, &str, bool)> = vec![
("utf8.java", GBK_SAMPLE.as_bytes().to_vec(), GBK_SAMPLE, "utf-8", false),
("bom.java", [b"\xEF\xBB\xBF", GBK_SAMPLE.as_bytes()].concat(), GBK_SAMPLE, "utf-8", true),
("gbk.java", gbk, GBK_SAMPLE, "gbk", false),
("utf16.java", utf16, GBK_SAMPLE, "utf-16le", true),
];
for (name, bytes, text, encoding, has_bom) in cases {
let jar_path = tmp.path().join(format!("lib-{name}.jar"));
jar_bytes(&jar_path, &[(&format!("p/{name}"), &bytes)]);
let loaded = read_entry(&jar_path, &format!("p/{name}"), None).unwrap_or_else(|| panic!("{name}"));
assert_eq!(loaded.text, text, "{name}");
assert_eq!(loaded.encoding, encoding, "{name}");
assert_eq!(loaded.has_bom, has_bom, "{name}");
assert!(!loaded.binary, "{name}");
}
// Binary entries are not shown as text.
let jar_path = tmp.path().join("bin.jar");
jar_bytes(&jar_path, &[("p/img.png", &[0x89, b'P', b'N', b'G', 0, 0, 0, 0])]);
assert!(read_entry(&jar_path, "p/img.png", None).is_none());
assert!(read_entry(&jar_path, "p/missing.java", None).is_none());
}
#[test]
fn reload_decodes_archive_entries_with_the_chosen_encoding() {
let tmp = tempfile::tempdir().unwrap();
let jar_path = tmp.path().join("lib.jar");
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
jar_bytes(&jar_path, &[("p/EncodingSample.java", &gbk)]);
let loaded = read_entry(&jar_path, "p/EncodingSample.java", Some("gbk")).unwrap();
assert_eq!((loaded.text.as_str(), loaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
// An explicit encoding wins over detection, even when it decodes to replacement characters.
let forced = read_entry(&jar_path, "p/EncodingSample.java", Some("utf-8")).unwrap();
assert!(forced.text.contains('\u{FFFD}'));
assert_eq!(forced.encoding, "utf-8");
assert!(read_entry(&jar_path, "p/EncodingSample.java", Some("not-an-encoding")).is_none());
}
#[test]
fn class_sources_in_maven_caches_decode_non_utf8() {
let tmp = tempfile::tempdir().unwrap();
let m2 = tmp.path().join("m2/com/example/encoding-sample/1.0.0");
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
let facade = "@file:JvmName(\"SampleUtils\")\npackage example.encoding\n\n/** 中文注释。 */\nfun message() = \"中文编码测试:是否启用\"\n";
let facade_gbk = encoding_rs::GBK.encode(facade).0.into_owned();
jar(&m2.join("encoding-sample-1.0.0.jar"), &[]);
jar_bytes(
&m2.join("encoding-sample-1.0.0-sources.jar"),
&[("example/encoding/EncodingSample.java", &gbk), ("example/encoding/Renamed.kt", &facade_gbk)],
);
let uri = |class: &str| {
format!("jar://{}!/{class}", m2.join("encoding-sample-1.0.0.jar").to_string_lossy().replace('\\', "/"))
};
for (class, text) in
[("example/encoding/EncodingSample.class", GBK_SAMPLE), ("example/encoding/SampleUtils.class", facade)]
{
let (source_uri, loaded) = class_source(&uri(class), None).unwrap_or_else(|| panic!("{class}"));
assert!(
source_uri.ends_with("-sources.jar!/example/encoding/EncodingSample.java")
|| source_uri.ends_with("-sources.jar!/example/encoding/Renamed.kt")
);
assert_eq!(loaded.text, text, "{class}");
assert_eq!(loaded.encoding, "gbk", "{class}");
// The manually attached archive path decodes the same way.
let jdt = "jdt://contents/encoding-sample-1.0.0.jar/example.encoding/EncodingSample.class?=project/x";
let (_, attached) = source_in_archive(jdt, &m2.join("encoding-sample-1.0.0-sources.jar"), None).unwrap();
assert_eq!((attached.text.as_str(), attached.encoding.as_str()), (GBK_SAMPLE, "gbk"));
}
// Reload with an explicit encoding re-reads the same source.
let (_, reloaded) = class_source(&uri("example/encoding/EncodingSample.class"), Some("gbk")).unwrap();
assert_eq!((reloaded.text.as_str(), reloaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None);
}
#[test]
-8
View File
@@ -357,14 +357,6 @@ pub struct VirtualDocument {
pub description: Option<String>,
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
pub decompiled: bool,
/// Charset the content was decoded with (a WHATWG label, e.g. `gbk`);
/// `utf-8` for text the server decoded itself.
pub encoding: String,
pub has_bom: bool,
/// "Reload with Encoding" can re-decode the origin's bytes (archive
/// entries, JDT sources with an editable attachment encoding); false for
/// server-generated content such as decompiled code.
pub can_reload_encoding: bool,
}
/// Reference updates for a file / folder move (`workspace/willRenameFiles`).
+10 -64
View File
@@ -23,20 +23,9 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'}');
pub fn path_to_uri_string(path: &Path) -> String {
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
} else {
path.to_string_lossy().into_owned()
};
let s = path.to_string_lossy();
let s = if cfg!(windows) { s.replace('\\', "/") } else { s.into_owned() };
let mut out = String::from("file://");
// `\\server\share\p` → `file://server/share/p` (RFC 8089 Appendix E.3).
let unc = s.strip_prefix("//?/UNC/").or_else(|| s.strip_prefix("//")).filter(|_| cfg!(windows));
if let Some(unc) = unc {
let (host, rest) = unc.split_at(unc.find('/').unwrap_or(unc.len()));
out.extend(utf8_percent_encode(host, PATH));
out.extend(utf8_percent_encode(rest, PATH));
return out;
}
if !s.starts_with('/') {
out.push('/');
}
@@ -52,30 +41,21 @@ pub fn path_to_uri(path: &Path) -> lsp_types::Uri {
})
}
/// Converts a `file:` URI to a path; `None` for other schemes. On Windows a
/// host names a share (`file://server/share/p` → `\\server\share\p`) and the
/// drive letter is upper-cased, as the rest of the IDE spells it.
/// Converts a `file:` URI to a path; `None` for other schemes.
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("FILE://"))?;
let (host, path) = rest.split_at(rest.find('/')?);
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
// Skip an authority (`file://localhost/...`).
let path = match rest.find('/') {
Some(0) => rest,
Some(i) if rest[..i].eq_ignore_ascii_case("localhost") => &rest[i..],
_ => return None,
};
let path = path.split(['?', '#']).next().unwrap_or(path);
let decoded = percent_decode_str(path).decode_utf8().ok()?;
if cfg!(windows) {
if !local {
let host = percent_decode_str(host).decode_utf8().ok()?;
return Some(PathBuf::from(format!(r"\\{host}{}", decoded.replace('/', "\\"))));
}
// `/C:/x` → `C:\x`
let trimmed = decoded.strip_prefix('/').filter(|s| s.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
let mut path = trimmed.replace('/', "\\");
if path.as_bytes().get(1) == Some(&b':') {
path[..1].make_ascii_uppercase();
}
return Some(PathBuf::from(path));
}
if !local {
return None;
return Some(PathBuf::from(trimmed.replace('/', "\\")));
}
Some(PathBuf::from(decoded.into_owned()))
}
@@ -109,40 +89,6 @@ mod tests {
assert_eq!(uri_to_path("jdt://contents/x"), None);
}
#[cfg(unix)]
#[test]
fn remote_hosts_are_not_local_paths() {
assert_eq!(uri_to_path("file://server/share/a.ts"), None);
}
#[cfg(windows)]
#[test]
fn windows_uris() {
for (uri, path) in [
("file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
("file:///c:/x/y.ts", r"C:\x\y.ts"),
("file:///c%3A/x/y.ts", r"C:\x\y.ts"),
("file:///C%3a/x", r"C:\x"),
("file://localhost/d:/x", r"D:\x"),
("file://server/share/a.ts", r"\\server\share\a.ts"),
("file://server/share/a%20b/c.ts", r"\\server\share\a b\c.ts"),
("file:////server/share/a.ts", r"\\server\share\a.ts"),
] {
let got = uri_to_path(uri).unwrap();
assert_eq!(got.as_os_str(), path, "{uri}");
}
assert_eq!(uri_to_path("file://server"), None);
for (path, uri, back) in [
(r"C:\a b\c.rs", "file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
(r"\\?\C:\x\y.ts", "file:///C:/x/y.ts", r"C:\x\y.ts"),
(r"\\server\share\a b.ts", "file://server/share/a%20b.ts", r"\\server\share\a b.ts"),
(r"\\?\UNC\server\share\a.ts", "file://server/share/a.ts", r"\\server\share\a.ts"),
] {
assert_eq!(path_to_uri(Path::new(path)).as_str(), uri, "{path}");
assert_eq!(uri_to_path(uri).unwrap().as_os_str(), back, "{path}");
}
}
#[test]
fn virtual_targets() {
let jdt = "jdt://contents/rt.jar/java.lang/String.class?=p/%5C/x.jar%3Cjava.lang(String.class";
-335
View File
@@ -306,7 +306,6 @@ fn harness_configured(
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -3669,340 +3668,6 @@ async fn jdt_class_file_contents() {
assert_eq!(source.title, "WithSources.java");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_library_sources_reload_with_the_attachment_encoding() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "import example.encoding.EncodingSample;\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/encoding-sample-1.0.jar/example.encoding/EncodingSample.class?=demo/%5C/m2%5C/encoding-sample-1.0.jar%3Cexample.encoding%28EncodingSample.class";
// The mock jdtls decodes the GBK attachment with the configured charset:
// until the encoding is updated, the text arrives garbled (U+FFFD).
let source = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {}";
let garbled =
"package example.encoding;\n\n/** \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} */\npublic final class EncodingSample {}";
let attachment_encoding: Arc<Mutex<Option<String>>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let encoding = attachment_encoding.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let text = if encoding.lock().as_deref() == Some("gbk") { source } else { garbled };
Some(json!(text))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"errorMessage": null,
"attributes": {
"jarPath": "/m2/encoding-sample-1.0.jar",
"sourceAttachmentPath": "/m2/encoding-sample-1.0-sources.jar",
"sourceAttachmentEncoding": encoding.lock().clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
let attrs = &params["arguments"][0]["attributes"];
*encoding.lock() = attrs["sourceAttachmentEncoding"].as_str().map(str::to_owned);
Some(json!({ "errorMessage": null, "attributes": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert_eq!(vd.text, garbled);
assert_eq!(vd.encoding, "utf-8", "no attachment encoding configured yet");
assert!(vd.can_reload_encoding);
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, source);
assert_eq!(reloaded.encoding, "gbk");
assert!(reloaded.can_reload_encoding);
// The update kept the attachment path (omitting it would drop the sources)
// and set the encoding; the contents were fetched again.
let updates: Vec<Value> = s
.requested("workspace/executeCommand")
.into_iter()
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
.collect();
assert_eq!(updates.len(), 1);
assert_eq!(updates[0]["arguments"][0]["classFileUri"], jdt);
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentPath"], "/m2/encoding-sample-1.0-sources.jar");
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentEncoding"], "gbk");
assert!(s.requested("java/classFileContents").len() >= 2);
// The cache serves the re-decoded text without another round trip.
assert_eq!(h.mgr.virtual_document(jdt).await.unwrap().text, source);
// Round trip back to UTF-8.
let back = h.mgr.reload_virtual(jdt, "utf-8").await.unwrap();
assert_eq!(back.text, garbled);
assert_eq!(back.encoding, "utf-8");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_waits_for_the_maven_container_refresh() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// m2e persists and refreshes the container in a background job: resolve
// keeps reporting the old encoding for a few calls after the update.
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let state = state.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let enc = state.lock().0.clone();
Some(json!(if enc.as_deref() == Some("gbk") {
"package p; /** 中文 */ class Lib {}"
} else {
"package p; /** \u{FFFD} */ class Lib {}"
}))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => {
let mut st = state.lock();
st.1 += 1;
let reported = if st.1 > 2 { st.0.clone() } else { None };
Some(json!({
"attributes": {
"sourceAttachmentPath": "/m2/lib-sources.jar",
"sourceAttachmentEncoding": reported,
"canEditEncoding": true,
}
}))
}
"java.project.updateSourceAttachment" => {
let mut st = state.lock();
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
st.1 = 0;
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
assert_eq!(reloaded.encoding, "gbk");
assert!(state.lock().1 >= 3, "polled until the container reported the new encoding");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_retries_when_a_restored_container_ignores_the_update() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// jdtls replaces the encoding attribute by reference equality: an entry
// restored from the saved container state keeps the old value on the first
// update (a duplicate attribute is appended); only a second update, after
// the container was refreshed, applies the new one.
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let state = state.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let enc = state.lock().0.clone();
Some(json!(if enc.as_deref() == Some("gbk") {
"package p; /** 中文 */ class Lib {}"
} else {
"package p; /** \u{FFFD} */ class Lib {}"
}))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"attributes": {
"sourceAttachmentPath": "/m2/lib-sources.jar",
"sourceAttachmentEncoding": state.lock().0.clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
let mut st = state.lock();
st.1 += 1;
if st.1 > 1 {
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"]
.as_str()
.map(str::to_owned);
}
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
assert_eq!(reloaded.encoding, "gbk");
let updates: Vec<Value> = s
.requested("workspace/executeCommand")
.into_iter()
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
.collect();
assert_eq!(updates.len(), 2, "the ignored first update was retried once");
assert!(updates.iter().all(|u| u["arguments"][0]["attributes"]["sourceAttachmentPath"] == "/m2/lib-sources.jar"));
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_reads_the_attachment_when_the_server_buffer_is_stale() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let sources = tmp.path().join("lib-sources.jar");
let source = "package p; /** 中文 */ class Lib {}";
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
let mut zip = zip::ZipWriter::new(std::fs::File::create(&sources).unwrap());
zip.start_file("p/Lib.java", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(&gbk).unwrap();
zip.finish().unwrap();
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// JDT caches the decoded buffer of a class file: even after the attachment
// encoding is updated, classFileContents keeps returning the old decoding.
let encoding: Arc<Mutex<Option<String>>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let encoding = encoding.clone();
let sources = sources.clone();
move |_, method, params| match method {
"java/classFileContents" => Some(json!("package p; /** \u{FFFD} */ class Lib {}")),
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"attributes": {
"sourceAttachmentPath": sources.to_string_lossy(),
"sourceAttachmentEncoding": encoding.lock().clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
*encoding.lock() =
params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, source, "read from the attachment, not the stale server buffer");
assert_eq!(reloaded.encoding, "gbk");
assert!(reloaded.can_reload_encoding);
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_is_refused_when_the_sources_cannot_be_re_decoded() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let class =
|name: &str| format!("jdt://contents/lib.jar/p/{name}.class?=demo/%5C/m2%5C/lib.jar%3Cp%28{name}.class");
s.respond("java/classFileContents", json!("package p;\npublic class Lib {}"));
// A container that does not allow editing the attachment encoding.
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
"attributes": { "sourceAttachmentPath": "/m2/lib-sources.jar", "canEditEncoding": false }
})),
_ => None,
}));
let vd = h.mgr.virtual_document(&class("ReadOnly")).await.unwrap();
assert!(!vd.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("ReadOnly"), "gbk").await.unwrap_err();
assert!(err.message.contains("cannot be changed"), "{err}");
// Decompiled content has no attached sources to re-decode.
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
"attributes": { "sourceAttachmentPath": null, "canEditEncoding": true }
})),
_ => None,
}));
s.respond(
"java/classFileContents",
json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Lib {}"),
);
let decompiled = h.mgr.virtual_document(&class("Decompiled")).await.unwrap();
assert!(decompiled.decompiled && !decompiled.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("Decompiled"), "gbk").await.unwrap_err();
assert!(err.message.contains("no attached sources"), "{err}");
// The resolve command fails (unqueryable container): no reload offered.
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "unsupported".into()));
let unresolved = h.mgr.virtual_document(&class("Unresolved")).await.unwrap();
assert_eq!(unresolved.encoding, "utf-8");
assert!(!unresolved.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("Unresolved"), "gbk").await.unwrap_err();
assert!(err.message.contains("cannot resolve"), "{err}");
// Server-generated content of other schemes cannot be re-decoded at all.
let err = h.mgr.reload_virtual("csharp:/p/App.csproj/decompiled/Lib.cs", "gbk").await.unwrap_err();
assert!(err.message.contains("generated by a language server"), "{err}");
// Unknown encodings are refused before any server round trip.
assert!(h.mgr.reload_virtual(&class("ReadOnly"), "not-an-encoding").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn jar_library_sources_reload_with_another_encoding() {
use std::io::Write;
let repo = tempfile::tempdir().unwrap();
let dir = repo.path().join("com/example/encoding-sample/1.0.0");
std::fs::create_dir_all(&dir).unwrap();
let source = "package example.encoding\n\n/** 中文编码测试。 */\nfun message() = \"中文编码测试:是否启用\"\n";
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
let jar_bytes = |path: &Path, entries: &[(&str, &[u8])]| {
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, bytes) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
};
jar_bytes(&dir.join("encoding-sample-1.0.jar"), &[("example/encoding/EncodingSampleKt.class", b"\0")]);
jar_bytes(&dir.join("encoding-sample-1.0-sources.jar"), &[("example/encoding/EncodingSample.kt", &gbk)]);
let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://"));
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
h.open("Main.kt", "fun main() {}\n");
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let class = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/EncodingSampleKt.class");
let entry = jar_uri(&dir.join("encoding-sample-1.0-sources.jar"), "example/encoding/EncodingSample.kt");
let vd = h.mgr.virtual_document(&class).await.unwrap();
assert_eq!(vd.text, source);
assert_eq!(vd.encoding, "gbk");
assert!(vd.can_reload_encoding);
// An explicit encoding wins over detection, even when it garbles the text.
let forced = h.mgr.reload_virtual(&class, "utf-8").await.unwrap();
assert!(forced.text.contains('\u{FFFD}'));
assert_eq!(forced.encoding, "utf-8");
let back = h.mgr.reload_virtual(&class, "gbk").await.unwrap();
assert_eq!((back.text.as_str(), back.encoding.as_str()), (source, "gbk"));
// Non-class entries reload the same way.
let entry_vd = h.mgr.virtual_document(&entry).await.unwrap();
assert_eq!((entry_vd.text.as_str(), entry_vd.encoding.as_str()), (source, "gbk"));
let entry_reload = h.mgr.reload_virtual(&entry, "gbk").await.unwrap();
assert_eq!(entry_reload.text, source);
// Missing sources fail cleanly.
let missing = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/Missing.class");
assert!(h.mgr.reload_virtual(&missing, "gbk").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn class_file_opening_routes_sources_and_binary_documents() {
let mut caps = incremental_caps();
-1
View File
@@ -55,7 +55,6 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
+1 -1
View File
@@ -69,7 +69,7 @@ pub struct TransferError {
}
impl TransferReport {
pub(crate) fn fail(&mut self, path: impl Into<String>, e: IdeError) {
fn fail(&mut self, path: impl Into<String>, e: IdeError) {
self.errors.push(TransferError { path: path.into(), message: e.message });
}
}
+1 -142
View File
@@ -1,4 +1,4 @@
//! Deployment over SFTP, FTP or local folders, and SSH run targets.
//! Deployment over SFTP or local folders, and SSH run targets.
//! Shared by IPC commands and project save/delete hooks.
use crate::deploy::{Deployment, FileStamp, SyncEntry, SyncStatus, SyncStep, TransferReport, status};
@@ -89,10 +89,6 @@ impl RemoteService {
let session = self.ssh.session(ssh).await?;
Ok(RemoteFs::new(session.sftp().await?))
}
FsConnection::Ftp { server, secure, ftp } => {
let password = self.ssh.password(&ide_ssh::ftp::secret_key(server));
Ok(RemoteFs::Ftp(ide_ssh::ftp::FtpFs::connect(ftp, *secure, password).await?))
}
}
}
@@ -290,70 +286,6 @@ impl RemoteService {
r
}
/// Remote Host › Download to…: `remotes` (folders recursively) into the local folder `dir`,
/// each under its own name, without the server path above it. Existing files are replaced.
pub async fn download_into(
&self,
connection: &FsConnection,
remotes: &[String],
dir: &Path,
cancel: &CancellationToken,
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
) -> IdeResult<TransferReport> {
let fs = self.fs(connection).await?;
let mut report = TransferReport::default();
let mut folders = Vec::new();
let mut jobs = Vec::new();
for remote in remotes {
let Some(top) = download_target(dir, remote, remote) else {
report.skipped += 1;
continue;
};
match fs.stat(remote).await {
Ok(Some(e)) if e.is_dir => {
folders.push(top);
let mut entries = Vec::new();
if let Err(e) = remote_tree(&fs, remote, cancel, &mut entries).await {
report.fail(remote.clone(), e);
}
for entry in entries {
match download_target(dir, remote, &entry.path) {
Some(local) if entry.is_dir => folders.push(local),
Some(local) => jobs.push((entry.path, local)),
None => report.skipped += 1,
}
}
}
Ok(Some(_)) => jobs.push((remote.clone(), top)),
Ok(None) => report.fail(remote.clone(), IdeError::not_found(format!("{remote} does not exist"))),
Err(e) => report.fail(remote.clone(), e),
}
}
for folder in &folders {
if let Err(e) = tokio::fs::create_dir_all(folder).await {
report.fail(folder.display().to_string(), e.into());
}
}
for (i, (remote, local)) in jobs.iter().enumerate() {
if cancel.is_cancelled() {
break;
}
progress(remote, i, jobs.len());
if let Some(parent) = local.parent()
&& let Err(e) = tokio::fs::create_dir_all(parent).await
{
report.fail(remote.clone(), e.into());
continue;
}
match fs.download(remote, local).await {
Ok(()) => report.files += 1,
Err(e) => report.fail(remote.clone(), e),
}
}
fs.close().await;
Ok(report)
}
// --- save hook -----------------------------------------------------------------
/// After a document save: uploads a remote file's copy, or (automatic upload)
@@ -556,11 +488,6 @@ pub fn connection(server: &DeploymentServer) -> FsConnection {
match server.kind {
DeploymentType::Local => FsConnection::Local,
DeploymentType::Sftp => FsConnection::Sftp { ssh: server.ssh_config.clone() },
DeploymentType::Ftp | DeploymentType::Ftps => FsConnection::Ftp {
server: server.id.clone(),
secure: server.kind == DeploymentType::Ftps,
ftp: server.ftp.clone(),
},
}
}
@@ -568,73 +495,5 @@ fn cache_key(connection: &FsConnection) -> String {
match connection {
FsConnection::Local => "local".to_owned(),
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
FsConnection::Ftp { server, .. } => format!("ftp-{server}"),
}
}
/// Files and folders under remote folder `dir` (symlinked folders are not followed).
async fn remote_tree(
fs: &RemoteFs,
dir: &str,
cancel: &CancellationToken,
out: &mut Vec<RemoteEntry>,
) -> IdeResult<()> {
for e in fs.list(dir).await? {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let descend = e.is_dir && !e.is_symlink;
let path = e.path.clone();
out.push(e);
if descend {
Box::pin(remote_tree(fs, &path, cancel, out)).await?;
}
}
Ok(())
}
/// Where remote `path`, inside the downloaded `selected` file or folder, lands under local `dir`:
/// `dir/<name of selected>/<rest>`. `None` for a name that would leave `dir`.
fn download_target(dir: &Path, selected: &str, path: &str) -> Option<PathBuf> {
let selected = selected.trim_end_matches('/');
let name = selected.rsplit('/').next()?;
let rest = path.trim_end_matches('/').strip_prefix(selected)?;
if !rest.is_empty() && !rest.starts_with('/') {
return None;
}
let mut local = dir.to_path_buf();
for part in std::iter::once(name).chain(rest.split('/').filter(|p| !p.is_empty())) {
if matches!(part, "" | "." | "..") || part.contains('\\') || (cfg!(windows) && part.contains(':')) {
return None;
}
local.push(part);
}
Some(local)
}
#[cfg(test)]
mod download_into_tests {
use super::*;
#[test]
fn downloads_keep_only_the_selected_name() {
let dir = Path::new("/p/project");
let selected = "/home/u/www/site/current/drush";
assert_eq!(download_target(dir, selected, selected), Some(dir.join("drush")));
assert_eq!(download_target(dir, "/home/u/www/site/current/drush/", selected), Some(dir.join("drush")));
assert_eq!(
download_target(dir, selected, "/home/u/www/site/current/drush/src/a.php"),
Some(dir.join("drush").join("src").join("a.php"))
);
assert_eq!(download_target(dir, "/srv/app.log", "/srv/app.log"), Some(dir.join("app.log")));
}
#[test]
fn names_that_would_leave_the_folder_are_refused() {
let dir = Path::new("/p/project");
assert_eq!(download_target(dir, "/srv/a", "/srv/a/../../etc/passwd"), None);
assert_eq!(download_target(dir, "/srv/a", "/srv/ab/x"), None, "a sibling with the same prefix");
assert_eq!(download_target(dir, "/", "/etc"), None, "the root has no name");
assert_eq!(download_target(dir, "/srv/..", "/srv/.."), None);
}
}
-34
View File
@@ -319,37 +319,3 @@ async fn browser_mappings_resolve_symlinks_and_download_to_the_project() {
assert!(report.errors.is_empty());
assert_eq!(std::fs::read_to_string(local).unwrap(), "from the server");
}
#[tokio::test]
async fn download_into_copies_only_the_selected_items() {
let tmp = tempfile::tempdir().unwrap();
let server = tmp.path().join("srv/www/site/current");
let project = tmp.path().join("project");
std::fs::create_dir_all(server.join("drush/sub")).unwrap();
std::fs::create_dir_all(server.join("drush/empty")).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(server.join("drush/a.txt"), "a").unwrap();
std::fs::write(server.join("drush/sub/b.txt"), "b").unwrap();
std::fs::write(server.join("index.php"), "php").unwrap();
std::fs::write(project.join("index.php"), "old").unwrap();
let service = service(tmp.path());
let remotes = [server.join("drush"), server.join("index.php")].map(|p| p.to_str().unwrap().to_owned());
let report = service
.download_into(&FsConnection::Local, &remotes, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!(report.files, 3, "{report:?}");
assert!(report.errors.is_empty(), "{report:?}");
assert_eq!(std::fs::read_to_string(project.join("drush/a.txt")).unwrap(), "a");
assert_eq!(std::fs::read_to_string(project.join("drush/sub/b.txt")).unwrap(), "b");
assert!(project.join("drush/empty").is_dir());
assert_eq!(std::fs::read_to_string(project.join("index.php")).unwrap(), "php");
assert!(!project.join("srv").exists() && !project.join("www").exists());
let missing = [server.join("gone").to_str().unwrap().to_owned()];
let report = service
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!((report.files, report.errors.len()), (0, 1), "{report:?}");
}
+4 -166
View File
@@ -2,10 +2,8 @@
use crate::exec::{executables_in, query, search_path, which};
use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, pinned, venv_of};
use ide_core::process::host_var_os;
use ide_core::version_managers::{self, Tool};
use std::collections::HashSet;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -134,66 +132,6 @@ fn conda_env_list_prefixes(output: &str) -> Vec<PathBuf> {
.collect()
}
/// The host's directory conventions; a parameter so tests cover every platform.
#[derive(Clone, Copy)]
enum Host {
Windows,
Mac,
Unix,
}
impl Host {
fn current() -> Self {
if cfg!(windows) {
Self::Windows
} else if cfg!(target_os = "macos") {
Self::Mac
} else {
Self::Unix
}
}
}
/// Where Poetry creates virtualenvs: `POETRY_VIRTUALENVS_PATH`, else `virtualenvs` in its
/// cache dir — `POETRY_CACHE_DIR`, else `%LOCALAPPDATA%\pypoetry\Cache` (Windows),
/// `~/Library/Caches/pypoetry` (macOS) or `$XDG_CACHE_HOME/pypoetry` / `~/.cache/pypoetry`.
fn poetry_venv_dirs(host: Host, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let cache = var("POETRY_CACHE_DIR").unwrap_or_else(|| match host {
Host::Windows => {
let local = var("LOCALAPPDATA").unwrap_or_else(|| home.join("AppData").join("Local"));
local.join("pypoetry").join("Cache")
}
Host::Mac => home.join("Library/Caches/pypoetry"),
Host::Unix => var("XDG_CACHE_HOME").unwrap_or_else(|| home.join(".cache")).join("pypoetry"),
});
var("POETRY_VIRTUALENVS_PATH").into_iter().chain([cache.join("virtualenvs")]).collect()
}
/// Where uv installs Pythons (`uv python dir`): `UV_PYTHON_INSTALL_DIR`, else `python` in its
/// data dir — `%APPDATA%\uv` on Windows (`%APPDATA%\uv\data` when an older uv made it), else
/// `$XDG_DATA_HOME/uv` / `~/.local/share/uv` (older uv on macOS: `~/Library/Application Support/uv`).
fn uv_python_dirs(host: Host, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let data = match host {
Host::Windows => {
let roaming = var("APPDATA").unwrap_or_else(|| home.join("AppData").join("Roaming"));
vec![roaming.join("uv"), roaming.join("uv").join("data")]
}
Host::Mac | Host::Unix => {
let xdg = var("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local/share")).join("uv");
let legacy = matches!(host, Host::Mac).then(|| home.join("Library/Application Support/uv"));
[xdg].into_iter().chain(legacy).collect()
}
};
var("UV_PYTHON_INSTALL_DIR").into_iter().chain(data.into_iter().map(|d| d.join("python"))).collect()
}
/// The interpreter of a uv-managed install: `python.exe` at its root on Windows, `bin/python3` elsewhere.
fn uv_python(install: &Path) -> Option<PathBuf> {
[install.join(exe("python")), install.join("bin").join(exe("python3"))].into_iter().find(|p| p.is_file())
}
fn python(root: Option<&Path>) -> Vec<Sdk> {
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
if let Some(root) = root {
@@ -217,10 +155,8 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
}
if let Some(home) = home_dir() {
// Virtualenvs managed by virtualenvwrapper / pipenv / poetry.
let env_dirs = [".virtualenvs", ".local/share/virtualenvs"].map(|d| home.join(d));
let var = |key: &str| host_var_os(key);
for dir in env_dirs.into_iter().chain(poetry_venv_dirs(Host::current(), &home, var)) {
for env in subdirs(&dir) {
for dir in [".virtualenvs", ".local/share/virtualenvs", "Library/Caches/pypoetry/virtualenvs"] {
for env in subdirs(&home.join(dir)) {
found.push((env.join(bin_dir()).join(exe("python")), "venv"));
}
}
@@ -228,8 +164,8 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
for v in subdirs(&pyenv.join("versions")) {
found.push((v.join(bin_dir()).join(exe("python")), "pyenv"));
}
for dir in uv_python_dirs(Host::current(), &home, var) {
found.extend(subdirs(&dir).iter().filter_map(|v| uv_python(v)).map(|p| (p, "uv")));
for v in subdirs(&home.join(".local/share/uv/python")) {
found.push((v.join(bin_dir()).join(exe("python3")), "uv"));
}
for (v, manager) in version_managers::installs(Tool::Python) {
found.extend(pinned::python_in_install(&v).map(|p| (p, manager)));
@@ -1062,101 +998,3 @@ legacy C:\\Users\\dev\\.conda\\envs\\legacy
);
}
}
#[cfg(test)]
mod env_manager_dir_tests {
use super::*;
use std::collections::HashMap;
fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
let vars: HashMap<String, OsString> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).into())).collect();
move |key| vars.get(key).cloned()
}
#[test]
fn poetry_venvs_follow_each_platforms_cache_dir() {
let home = Path::new("/home/me");
let local = Path::new("C:/Users/me/AppData/Local");
assert_eq!(
poetry_venv_dirs(Host::Windows, home, env(&[("LOCALAPPDATA", "C:/Users/me/AppData/Local")])),
vec![local.join("pypoetry").join("Cache").join("virtualenvs")]
);
assert_eq!(
poetry_venv_dirs(Host::Windows, home, env(&[])),
vec![home.join("AppData").join("Local").join("pypoetry").join("Cache").join("virtualenvs")]
);
assert_eq!(
poetry_venv_dirs(Host::Mac, home, env(&[])),
vec![home.join("Library").join("Caches").join("pypoetry").join("virtualenvs")]
);
assert_eq!(
poetry_venv_dirs(Host::Unix, home, env(&[])),
vec![home.join(".cache").join("pypoetry").join("virtualenvs")]
);
assert_eq!(
poetry_venv_dirs(Host::Unix, home, env(&[("XDG_CACHE_HOME", "/xdg/cache")])),
vec![Path::new("/xdg/cache").join("pypoetry").join("virtualenvs")]
);
}
#[test]
fn poetry_overrides_come_first_and_empty_ones_are_ignored() {
let home = Path::new("/home/me");
assert_eq!(
poetry_venv_dirs(
Host::Unix,
home,
env(&[("POETRY_VIRTUALENVS_PATH", "/envs"), ("POETRY_CACHE_DIR", "/cache")])
),
vec![PathBuf::from("/envs"), Path::new("/cache").join("virtualenvs")]
);
assert_eq!(
poetry_venv_dirs(Host::Unix, home, env(&[("POETRY_VIRTUALENVS_PATH", ""), ("POETRY_CACHE_DIR", "")])),
vec![home.join(".cache").join("pypoetry").join("virtualenvs")]
);
}
#[test]
fn uv_pythons_follow_each_platforms_data_dir() {
let home = Path::new("/home/me");
let roaming = Path::new("C:/Users/me/AppData/Roaming");
assert_eq!(
uv_python_dirs(Host::Windows, home, env(&[("APPDATA", "C:/Users/me/AppData/Roaming")])),
vec![roaming.join("uv").join("python"), roaming.join("uv").join("data").join("python")]
);
assert_eq!(
uv_python_dirs(Host::Mac, home, env(&[])),
vec![
home.join(".local").join("share").join("uv").join("python"),
home.join("Library").join("Application Support").join("uv").join("python"),
]
);
assert_eq!(
uv_python_dirs(Host::Unix, home, env(&[("XDG_DATA_HOME", "/xdg/data")])),
vec![Path::new("/xdg/data").join("uv").join("python")]
);
assert_eq!(
uv_python_dirs(Host::Unix, home, env(&[("UV_PYTHON_INSTALL_DIR", "/pythons")])),
vec![PathBuf::from("/pythons"), home.join(".local").join("share").join("uv").join("python")]
);
}
#[test]
fn a_uv_install_finds_its_interpreter_on_either_layout() {
let tmp = tempfile::tempdir().unwrap();
// Windows: python.exe at the install root.
let windows = tmp.path().join("cpython-3.13.15-windows-x86_64-none");
std::fs::create_dir_all(&windows).unwrap();
std::fs::write(windows.join(exe("python")), "").unwrap();
assert_eq!(uv_python(&windows), Some(windows.join(exe("python"))));
// Unix: bin/python3.
let unix = tmp.path().join("cpython-3.13.15-linux-x86_64-gnu");
std::fs::create_dir_all(unix.join("bin")).unwrap();
std::fs::write(unix.join("bin").join(exe("python3")), "").unwrap();
assert_eq!(uv_python(&unix), Some(unix.join("bin").join(exe("python3"))));
// uv's own bookkeeping folders (`.temp`, `.cache`) hold no interpreter.
let temp = tmp.path().join(".temp");
std::fs::create_dir_all(&temp).unwrap();
assert_eq!(uv_python(&temp), None);
}
}
+1 -1
View File
@@ -1541,7 +1541,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
install_bundle(
data_dir,
"java-usages",
"1.13.0",
"1.12.0",
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
)
}
+1 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "ide-ssh"
description = "SSH connections: configurations, host keys, authentication, remote commands, SFTP and FTP files, port forwarding and askpass"
description = "SSH connections: configurations, host keys, authentication, remote commands, SFTP, port forwarding and askpass"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -20,10 +20,6 @@ keyring-core.workspace = true
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
russh-sftp = "2.4"
flate2 = "1"
suppaftp = { version = "12.2.1", default-features = false, features = ["tokio-rustls-ring"] }
rustls.workspace = true
rustls-platform-verifier.workspace = true
tokio-rustls = { version = "0.26", default-features = false }
[features]
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one,
+3 -30
View File
@@ -1,7 +1,5 @@
//! Filesystem operations shared by deployment and remote development bootstrap.
use crate::ftp::FtpFs;
use crate::sftp::{RemoteEntry, SftpFs};
use ide_core::remote_settings::FtpConnection;
use ide_core::{CancellationToken, IdeError, IdeResult};
use russh_sftp::client::SftpSession;
use serde::{Deserialize, Serialize};
@@ -11,21 +9,12 @@ use std::path::Path;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum FsConnection {
Sftp {
ssh: String,
},
/// `server`: the deployment server whose saved password is used.
Ftp {
server: String,
secure: bool,
ftp: FtpConnection,
},
Sftp { ssh: String },
Local,
}
pub enum RemoteFs {
Sftp(SftpFs),
Ftp(FtpFs),
Local,
}
@@ -38,7 +27,6 @@ impl RemoteFs {
pub async fn home(&self) -> IdeResult<String> {
match self {
Self::Sftp(fs) => fs.home().await,
Self::Ftp(fs) => Ok(fs.home()),
Self::Local => {
dirs::home_dir().map(|p| path_string(&p)).ok_or_else(|| IdeError::invalid("Home folder is unavailable"))
}
@@ -48,7 +36,6 @@ impl RemoteFs {
pub async fn resolve(&self, path: &str) -> IdeResult<String> {
match self {
Self::Sftp(fs) => fs.resolve(path).await,
Self::Ftp(fs) => Ok(fs.resolve(path)),
Self::Local => {
let expanded = crate::expand(path.trim());
let path = if path.trim().is_empty() || path.trim() == "~" {
@@ -65,7 +52,6 @@ impl RemoteFs {
pub async fn stat(&self, path: &str) -> IdeResult<Option<RemoteEntry>> {
match self {
Self::Sftp(fs) => fs.stat(path).await,
Self::Ftp(fs) => fs.stat(path).await,
Self::Local => match tokio::fs::metadata(path).await {
Ok(meta) => Ok(Some(local_entry(path, meta).await?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
@@ -77,7 +63,6 @@ impl RemoteFs {
pub async fn list(&self, dir: &str) -> IdeResult<Vec<RemoteEntry>> {
match self {
Self::Sftp(fs) => fs.list(dir).await,
Self::Ftp(fs) => fs.list(dir).await,
Self::Local => {
let mut read = tokio::fs::read_dir(dir).await?;
let mut entries = Vec::new();
@@ -96,7 +81,6 @@ impl RemoteFs {
pub async fn read(&self, path: &str) -> IdeResult<Vec<u8>> {
match self {
Self::Sftp(fs) => fs.read(path).await,
Self::Ftp(fs) => fs.read(path).await,
Self::Local => Ok(tokio::fs::read(path).await?),
}
}
@@ -104,7 +88,6 @@ impl RemoteFs {
pub async fn write(&self, path: &str, data: &[u8], mtime: Option<u32>) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.write(path, data, mtime).await,
Self::Ftp(fs) => fs.write(path, data, mtime).await,
Self::Local => {
if let Some(parent) = Path::new(path).parent() {
tokio::fs::create_dir_all(parent).await?;
@@ -121,7 +104,6 @@ impl RemoteFs {
pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.upload(local, remote, preserve_time).await,
Self::Ftp(fs) => fs.upload(local, remote, preserve_time).await,
Self::Local => {
reject_same_file(local, Path::new(remote)).await?;
copy_file(local, Path::new(remote)).await?;
@@ -140,7 +122,6 @@ impl RemoteFs {
}
match self {
Self::Sftp(fs) => fs.upload_new(local, remote, cancel).await,
Self::Ftp(fs) => fs.upload_new(local, remote, cancel).await,
Self::Local => {
let mut input = tokio::fs::File::open(local).await?;
let mut output = tokio::fs::OpenOptions::new().write(true).create_new(true).open(remote).await?;
@@ -164,7 +145,6 @@ impl RemoteFs {
pub async fn symlink(&self, target: &str, link: &str) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.symlink(target, link).await,
Self::Ftp(_) => Err(IdeError::invalid("Cannot create a symbolic link over FTP")),
#[cfg(unix)]
Self::Local => Ok(tokio::fs::symlink(target, link).await?),
#[cfg(not(unix))]
@@ -175,7 +155,6 @@ impl RemoteFs {
pub async fn create_dir(&self, path: &str) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.create_dir(path).await,
Self::Ftp(fs) => fs.create_dir(path).await,
Self::Local => Ok(tokio::fs::create_dir(path).await?),
}
}
@@ -183,7 +162,6 @@ impl RemoteFs {
pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.download(remote, local).await,
Self::Ftp(fs) => fs.download(remote, local).await,
Self::Local => {
reject_same_file(Path::new(remote), local).await?;
copy_file(Path::new(remote), local).await
@@ -194,7 +172,6 @@ impl RemoteFs {
pub async fn mkdir_all(&self, path: &str) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.mkdir_all(path).await,
Self::Ftp(fs) => fs.mkdir_all(path).await,
Self::Local => Ok(tokio::fs::create_dir_all(path).await?),
}
}
@@ -202,7 +179,6 @@ impl RemoteFs {
pub async fn remove(&self, path: &str) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.remove(path).await,
Self::Ftp(fs) => fs.remove(path).await,
Self::Local => {
let meta = tokio::fs::symlink_metadata(path).await?;
if meta.is_dir() {
@@ -218,7 +194,6 @@ impl RemoteFs {
pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.rename(from, to).await,
Self::Ftp(fs) => fs.rename(from, to).await,
Self::Local => Ok(tokio::fs::rename(from, to).await?),
}
}
@@ -262,10 +237,8 @@ impl RemoteFs {
}
pub async fn close(&self) {
match self {
Self::Sftp(fs) => fs.close().await,
Self::Ftp(fs) => fs.close().await,
Self::Local => {}
if let Self::Sftp(fs) = self {
fs.close().await;
}
}
}
-379
View File
@@ -1,379 +0,0 @@
//! Remote files over FTP, or FTPS with `AUTH TLS`. Remote paths are POSIX strings.
use crate::sftp::{RemoteEntry, join, parent};
use ide_core::remote_settings::FtpConnection;
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use suppaftp::list::{File, ListParser, PosixPexQuery};
use suppaftp::tokio::{AsyncRustlsConnector, AsyncRustlsFtpStream};
use suppaftp::types::FileType;
use suppaftp::{FtpError, Mode, Status};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
/// Key of an FTP deployment server's password among the saved passwords.
pub fn secret_key(server: &str) -> String {
format!("ftp:{server}")
}
/// The server's reply text (it starts with the reply code), or the error.
fn describe(e: &FtpError) -> String {
match e {
FtpError::UnexpectedResponse(r) => r.as_string().unwrap_or_else(|_| r.to_string()),
FtpError::SecureError(s) => s.trim_start_matches("Secure error: ").to_owned(),
e => e.to_string(),
}
}
fn err(path: &str, e: FtpError) -> IdeError {
let kind = match &e {
FtpError::UnexpectedResponse(r) if r.status == Status::FileUnavailable => ErrorKind::NotFound,
FtpError::UnexpectedResponse(r) if r.status == Status::NotLoggedIn => ErrorKind::PermissionDenied,
_ => ErrorKind::Io,
};
IdeError::new(kind, format!("{path}: {}", describe(&e)))
}
fn tls() -> IdeResult<AsyncRustlsConnector> {
use rustls_platform_verifier::BuilderVerifierExt;
let fail = |e: String| IdeError::internal(format!("FTPS: {e}"));
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| fail(e.to_string()))?
.with_platform_verifier()
.map_err(|e| fail(e.to_string()))?
.with_no_client_auth();
Ok(tokio_rustls::TlsConnector::from(Arc::new(config)).into())
}
fn permissions(f: &File) -> String {
[PosixPexQuery::Owner, PosixPexQuery::Group, PosixPexQuery::Others]
.into_iter()
.flat_map(|q| {
[
if f.can_read(q) { 'r' } else { '-' },
if f.can_write(q) { 'w' } else { '-' },
if f.can_execute(q) { 'x' } else { '-' },
]
})
.collect()
}
fn seconds(t: SystemTime) -> Option<u32> {
t.duration_since(UNIX_EPOCH).ok()?.as_secs().try_into().ok()
}
/// `YYYYMMDDHHMMSS` (UTC) for `MFMT`.
fn mfmt_time(seconds: u32) -> String {
let days = i64::from(seconds / 86_400);
let rem = seconds % 86_400;
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}{month:02}{day:02}{:02}{:02}{:02}", rem / 3600, rem / 60 % 60, rem % 60)
}
/// One logged-in FTP control connection; operations take turns on it.
pub struct FtpFs {
ftp: tokio::sync::Mutex<AsyncRustlsFtpStream>,
home: String,
/// `MLSD` listings (exact UTC times); otherwise `LIST`, with `MDTM` for times when supported.
mlsd: bool,
mdtm: bool,
}
impl FtpFs {
/// Connects and logs in; an empty user logs in anonymously.
pub async fn connect(c: &FtpConnection, secure: bool, password: Option<String>) -> IdeResult<Self> {
let host = c.host.trim();
if host.is_empty() {
return Err(IdeError::invalid("the deployment server has no FTP host"));
}
let port = c.port.unwrap_or(21);
let user = c.user.trim();
let label = if user.is_empty() { format!("{host}:{port}") } else { format!("{user}@{host}:{port}") };
let (user, password) = if user.is_empty() {
("anonymous", "anonymous@".to_owned())
} else {
let password = password.ok_or_else(|| {
IdeError::new(ErrorKind::PermissionDenied, format!("FTP password required for {label}"))
})?;
(user, password)
};
let connect = async {
let mut ftp = AsyncRustlsFtpStream::connect((host, port)).await.map_err(|e| err(&label, e))?;
if secure {
ftp = ftp.into_secure(tls()?, host).await.map_err(|e| err(&label, e))?;
}
IdeResult::Ok(ftp)
};
let mut ftp = tokio::time::timeout(CONNECT_TIMEOUT, connect)
.await
.map_err(|_| IdeError::new(ErrorKind::Io, format!("{label}: connection timed out")))??;
if let Err(e) = ftp.login(user, password.as_str()).await {
return Err(match e {
FtpError::UnexpectedResponse(r) if r.status == Status::NotLoggedIn => {
let reply = describe(&FtpError::UnexpectedResponse(r));
IdeError::new(
ErrorKind::PermissionDenied,
format!("FTP authentication failed for {label}: {reply}"),
)
}
e => err(&label, e),
});
}
ftp.transfer_type(FileType::Binary).await.map_err(|e| err(&label, e))?;
if c.passive {
ftp.set_mode(Mode::Passive);
// Servers behind NAT often announce a private address; the data
// connection goes to the host of the control connection instead.
ftp.set_passive_nat_workaround(true);
} else {
ftp = ftp.active_mode(CONNECT_TIMEOUT);
}
let home = ftp.pwd().await.map_err(|e| err(&label, e))?;
let features = ftp.feat().await.unwrap_or_default();
let (mlsd, mdtm) = (features.contains_key("MLST"), features.contains_key("MDTM"));
Ok(Self { ftp: tokio::sync::Mutex::new(ftp), home, mlsd, mdtm })
}
pub fn home(&self) -> String {
self.home.clone()
}
/// Absolute form of `path`; `~` and `~/…` are relative to the login directory.
pub fn resolve(&self, path: &str) -> String {
let path = path.trim();
if path.is_empty() || path == "~" {
return self.home.clone();
}
if let Some(rest) = path.strip_prefix("~/") {
return join(&self.home, rest);
}
if path.starts_with('/') { path.to_owned() } else { join(&self.home, path) }
}
async fn is_dir(&self, path: &str) -> bool {
self.ftp.lock().await.cwd(path).await.is_ok()
}
/// The entry of `path` as its parent lists it (FTP has no portable stat).
pub async fn stat(&self, path: &str) -> IdeResult<Option<RemoteEntry>> {
let path = path.trim_end_matches('/');
let Some(dir) = parent(path) else {
return Ok(self.is_dir("/").await.then(|| RemoteEntry {
name: "/".into(),
path: "/".into(),
is_dir: true,
is_symlink: false,
size: 0,
mtime: None,
permissions: String::new(),
}));
};
let name = &path[dir.len()..].trim_start_matches('/');
match self.list(dir).await {
Ok(entries) => Ok(entries.into_iter().find(|e| e.name == *name)),
Err(e) if e.kind == ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
/// Entries of `dir`, folders first, then by name; symlinks to folders count as folders.
pub async fn list(&self, dir: &str) -> IdeResult<Vec<RemoteEntry>> {
let lines = {
let mut ftp = self.ftp.lock().await;
if self.mlsd { ftp.mlsd(Some(dir)).await } else { ftp.list(Some(dir)).await }.map_err(|e| err(dir, e))?
};
let mut out = Vec::new();
for line in lines {
// Permissions only when the listing has them (MLSD's optional `unix.mode`, a POSIX `LIST` line).
let parsed = if self.mlsd {
ListParser::parse_mlsd(&line).map(|f| (f, line.to_ascii_lowercase().contains("unix.mode=")))
} else {
ListParser::parse_posix(&line)
.map(|f| (f, true))
.or_else(|_| ListParser::parse_dos(&line).map(|f| (f, false)))
};
let Ok((f, has_mode)) = parsed else { continue };
let name = f.name().to_owned();
// A name with `/` (from a hostile server) would point elsewhere once joined.
if name.is_empty() || name == "." || name == ".." || name.contains('/') {
continue;
}
let path = join(dir, &name);
let is_dir = f.is_directory() || (f.is_symlink() && self.is_dir(&path).await);
// `LIST` times are to the minute at best, in the server's time zone.
let mtime = if self.mlsd {
seconds(f.modified())
} else if self.mdtm && !is_dir {
let exact = self.ftp.lock().await.mdtm(&path).await;
exact.ok().and_then(|t| t.and_utc().timestamp().try_into().ok())
} else {
None
};
out.push(RemoteEntry {
is_dir,
is_symlink: f.is_symlink(),
size: f.size() as u64,
mtime,
permissions: if has_mode { permissions(&f) } else { String::new() },
name,
path,
});
}
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
pub async fn read(&self, path: &str) -> IdeResult<Vec<u8>> {
let mut ftp = self.ftp.lock().await;
let mut stream = ftp.retr_as_stream(path).await.map_err(|e| err(path, e))?;
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await?;
stream.finish().await.map_err(|e| err(path, e))?;
Ok(buf)
}
async fn store(
&self,
path: &str,
input: &mut (impl tokio::io::AsyncRead + Unpin),
cancel: Option<&CancellationToken>,
) -> IdeResult<()> {
if let Some(dir) = parent(path) {
self.mkdir_all(dir).await?;
}
let mut ftp = self.ftp.lock().await;
let mut stream = ftp.put_with_stream(path).await.map_err(|e| err(path, e))?;
match cancel {
Some(cancel) => crate::filesystem::copy_stream(input, &mut stream, cancel).await?,
None => {
tokio::io::copy(input, &mut stream).await?;
}
}
stream.finish().await.map_err(|e| err(path, e))
}
async fn set_mtime(&self, path: &str, seconds: u32) {
let command = format!("MFMT {} {path}", mfmt_time(seconds));
if let Err(e) = self.ftp.lock().await.custom_command(command, &[Status::File]).await {
tracing::debug!("FTP: cannot set the time of {path}: {e}");
}
}
/// Writes `data`, creating missing parent folders.
pub async fn write(&self, path: &str, data: &[u8], mtime: Option<u32>) -> IdeResult<()> {
self.store(path, &mut &data[..], None).await?;
if let Some(t) = mtime {
self.set_mtime(path, t).await;
}
Ok(())
}
pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> {
let mut input = tokio::fs::File::open(local).await?;
self.store(remote, &mut input, None).await?;
if let Some(t) = preserve_time.then(|| crate::sftp::mtime_of(local)).flatten() {
self.set_mtime(remote, t).await;
}
Ok(())
}
/// Uploads unless `remote` exists (FTP has no exclusive create: checked first).
pub async fn upload_new(&self, local: &Path, remote: &str, cancel: &CancellationToken) -> IdeResult<()> {
if self.stat(remote).await?.is_some() {
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{remote} already exists")));
}
let mut input = tokio::fs::File::open(local).await?;
let result = self.store(remote, &mut input, Some(cancel)).await;
if result.is_err() {
let _ = self.ftp.lock().await.rm(remote).await;
}
result
}
pub async fn create_dir(&self, path: &str) -> IdeResult<()> {
self.ftp.lock().await.mkdir(path).await.map_err(|e| err(path, e))
}
pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> {
if let Some(dir) = local.parent() {
tokio::fs::create_dir_all(dir).await?;
}
let mut ftp = self.ftp.lock().await;
let mut stream = ftp.retr_as_stream(remote).await.map_err(|e| err(remote, e))?;
let mut output = tokio::fs::File::create(local).await?;
tokio::io::copy(&mut stream, &mut output).await?;
output.flush().await?;
stream.finish().await.map_err(|e| err(remote, e))
}
/// `mkdir -p`.
pub async fn mkdir_all(&self, dir: &str) -> IdeResult<()> {
if dir.is_empty() || dir == "/" || self.is_dir(dir).await {
return Ok(());
}
if let Some(p) = parent(dir) {
Box::pin(self.mkdir_all(p)).await?;
}
match self.ftp.lock().await.mkdir(dir).await {
Ok(()) => Ok(()),
Err(e) => {
if self.is_dir(dir).await {
Ok(())
} else {
Err(err(dir, e))
}
}
}
}
/// Deletes a file, or a folder with its contents (a symlink itself, never its target).
pub async fn remove(&self, path: &str) -> IdeResult<()> {
let entry = self.stat(path).await?.ok_or_else(|| IdeError::not_found(format!("{path} does not exist")))?;
if entry.is_dir && !entry.is_symlink {
for e in self.list(path).await? {
if e.is_dir && !e.is_symlink {
Box::pin(self.remove(&e.path)).await?;
} else {
self.ftp.lock().await.rm(&e.path).await.map_err(|x| err(&e.path, x))?;
}
}
self.ftp.lock().await.rmdir(path).await.map_err(|e| err(path, e))
} else {
self.ftp.lock().await.rm(path).await.map_err(|e| err(path, e))
}
}
pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> {
self.ftp.lock().await.rename(from, to).await.map_err(|e| err(from, e))
}
pub async fn close(&self) {
let _ = self.ftp.lock().await.quit().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_mfmt_times_in_utc() {
assert_eq!(mfmt_time(0), "19700101000000");
assert_eq!(mfmt_time(951_782_400), "20000229000000");
assert_eq!(mfmt_time(1_794_484_245), "20261112115045");
}
}
+1 -2
View File
@@ -1,10 +1,9 @@
//! SSH for every remote feature: configurations and `~/.ssh/config` hosts,
//! host-key trust, authentication (password, key, agent), remote commands and
//! processes, SFTP (and FTP for deployment), local port forwarding, and askpass for `ssh` / `git`.
//! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`.
pub mod askpass;
pub mod filesystem;
pub mod ftp;
pub mod git_creds;
pub mod manager;
pub mod secrets;
-5
View File
@@ -105,11 +105,6 @@ impl SshManager {
}
}
/// A password entered this session or saved under `key` (not an SSH configuration's).
pub fn password(&self, key: &str) -> Option<String> {
self.entered.lock().get(key).cloned().or_else(|| self.saved.get(key))
}
/// The known password / passphrase of configuration `id`.
pub fn secret(&self, config: &SshConfiguration) -> Option<String> {
if let Some(s) = self.entered.lock().get(&config.id) {
+1 -4
View File
@@ -177,10 +177,7 @@ fn interchangeable_servers_list_their_alternatives() {
assert_eq!(find(&list, "vtsls").alternatives, ["vtsls", "typescript-language-server"]);
for name in ["jdtls", "kmp-lsp"] {
assert_eq!(find(&list, name).alternatives, ["jdtls", "kmp-lsp"]);
assert_eq!(find(&list, name).languages, if name == "kmp-lsp" { &["Java", "Kotlin"][..] } else { &["Java"] });
}
for name in ["kotlin-lsp", "kotlin-language-server"] {
assert_eq!(find(&list, name).alternatives, ["kotlin-lsp", "kotlin-language-server", "kmp-lsp"]);
assert_eq!(find(&list, name).languages, ["Java"]);
}
// A regular server after a fallback starts no group with it.
assert!(find(&list, "angular-language-server").alternatives.is_empty());
+21 -142
View File
@@ -5,7 +5,6 @@ use crate::{ChangeKind, FileStatus, Repo};
use gix::status::index_worktree::Item as WtItem;
use gix::status::plumbing::index_as_worktree::{Change as WtChange, EntryStatus};
use ide_core::IdeResult;
use ide_core::ignored::IgnoredNames;
use std::collections::{BTreeMap, HashSet};
#[derive(Default)]
@@ -16,43 +15,10 @@ struct Entry {
}
pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// gitoxide stops at the first IO error (a directory it may not read), where git warns
// and lists the rest: git answers then.
let (map, conflicts) = gix_status(r, &ignored).or_else(|e| {
tracing::debug!("status via gitoxide failed, asking git: {}", e.message);
cli_status(r, &ignored)
})?;
Ok(map
.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
// cannot stall on a file the user cannot see.
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
.map(|(rel, mut e)| {
if conflicts.contains(&rel) {
e.staged = None;
e.renamed_from = None;
e.unstaged = Some(ChangeKind::Conflicted);
}
FileStatus {
path: r.abs_path(&rel).into(),
rel_path: rel,
staged: e.staged,
unstaged: e.unstaged,
renamed_from: e.renamed_from,
}
})
.collect())
}
type Entries = (BTreeMap<String, Entry>, HashSet<String>);
fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
let repo = r.gix()?;
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
let mut conflicts: HashSet<String> = HashSet::new();
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// Stamp before status can read the index: stamping after the load could capture
// an external `git switch`, `add` or commit's new index and allow the stale
@@ -152,60 +118,28 @@ fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
}
}
Ok((map, conflicts))
}
/// `git status --porcelain=v2`: what git lists, unreadable directories skipped.
fn cli_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
let out = crate::cli::run(r.root(), &["status", "--porcelain=v2", "-z", "--untracked-files=all"], None)?;
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
fn parse_porcelain_v2(out: &str, ignored: &IgnoredNames) -> Entries {
let kind = |c: u8| match c {
b'M' | b'T' => Some(ChangeKind::Modified),
b'A' | b'C' => Some(ChangeKind::Added),
b'D' => Some(ChangeKind::Deleted),
b'R' => Some(ChangeKind::Renamed),
_ => None,
};
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
let mut conflicts: HashSet<String> = HashSet::new();
let mut records = out.split('\0');
while let Some(record) = records.next() {
let (tag, rest) = record.split_once(' ').unwrap_or((record, ""));
match tag {
// `1 XY sub mH mI mW hH hI path`, `2 … Xscore path` + NUL + the original path.
"1" | "2" => {
let fields = if tag == "1" { 8 } else { 9 };
let mut parts = rest.splitn(fields, ' ');
let xy = parts.next().unwrap_or("..").as_bytes();
let Some(path) = parts.nth(fields - 2) else { continue };
let e = map.entry(path.to_owned()).or_default();
e.staged = xy.first().copied().and_then(kind);
// Intent to add shows as `.A`; worktree renames are off, as with gitoxide.
e.unstaged = xy.get(1).copied().and_then(kind).filter(|k| *k != ChangeKind::Renamed);
if tag == "2" {
let from = records.next().unwrap_or_default();
if e.staged == Some(ChangeKind::Renamed) {
e.renamed_from = Some(from.to_owned());
}
}
Ok(map
.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
// cannot stall on a file the user cannot see.
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
.map(|(rel, mut e)| {
if conflicts.contains(&rel) {
e.staged = None;
e.renamed_from = None;
e.unstaged = Some(ChangeKind::Conflicted);
}
// `u XY sub m1 m2 m3 mW h1 h2 h3 path`
"u" => {
if let Some(path) = rest.splitn(10, ' ').nth(9) {
conflicts.insert(path.to_owned());
map.entry(path.to_owned()).or_default().unstaged = Some(ChangeKind::Conflicted);
}
FileStatus {
path: r.abs_path(&rel).into(),
rel_path: rel,
staged: e.staged,
unstaged: e.unstaged,
renamed_from: e.renamed_from,
}
"?" if !rest.is_empty() && !ignored.is_ignored_path(rest) => {
map.entry(rest.to_owned()).or_default().unstaged = Some(ChangeKind::Untracked);
}
_ => {}
}
}
(map, conflicts)
})
.collect())
}
/// Lets a test land an external index write in the window between the status run
@@ -293,59 +227,4 @@ mod tests {
let staged = Command::new("git").current_dir(dir).args(["diff", "--cached", "--stat"]).output().unwrap();
assert!(staged.stdout.is_empty(), "{}", String::from_utf8_lossy(&staged.stdout));
}
/// git's porcelain answer (used when gitoxide can't read the tree) matches gitoxide's.
#[test]
fn git_and_gitoxide_report_the_same_status() {
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"]);
for f in ["modified.txt", "staged.txt", "deleted.txt", "staged_del.txt", "both.txt", "conflict.txt"] {
std::fs::write(dir.join(f), "a\n").unwrap();
}
std::fs::write(dir.join("rename_me.txt"), "some longer content\nthat is stable\nfor rename detection\n")
.unwrap();
commit_all(dir, "base");
git(dir, &["checkout", "-q", "-b", "side"]);
std::fs::write(dir.join("conflict.txt"), "side\n").unwrap();
commit_all(dir, "side");
git(dir, &["checkout", "-q", "main"]);
std::fs::write(dir.join("conflict.txt"), "main\n").unwrap();
commit_all(dir, "main");
let merge = Command::new("git").current_dir(dir).args(["merge", "-q", "side"]).output().unwrap();
assert!(!merge.status.success());
std::fs::write(dir.join("modified.txt"), "b\n").unwrap();
std::fs::write(dir.join("staged.txt"), "b\n").unwrap();
git(dir, &["add", "staged.txt"]);
std::fs::remove_file(dir.join("deleted.txt")).unwrap();
git(dir, &["rm", "-q", "staged_del.txt"]);
std::fs::write(dir.join("both.txt"), "b\n").unwrap();
git(dir, &["add", "both.txt"]);
std::fs::write(dir.join("both.txt"), "c\n").unwrap();
git(dir, &["mv", "rename_me.txt", "renamed with space.txt"]);
std::fs::create_dir_all(dir.join("new dir")).unwrap();
std::fs::write(dir.join("new dir/untracked.txt"), "u\n").unwrap();
std::fs::write(dir.join("intent.txt"), "i\n").unwrap();
git(dir, &["add", "-N", "intent.txt"]);
let repo = Repo::discover(dir).unwrap().expect("repository found");
let ignored = IgnoredNames::default();
let show = |(map, conflicts): Entries| {
let mut c: Vec<_> = conflicts.into_iter().collect();
c.sort();
let m: Vec<_> = map
.into_iter()
.map(|(k, e)| format!("{k}: {:?} {:?} {:?}", e.staged, e.unstaged, e.renamed_from))
.collect();
(m, c)
};
let from_git = show(cli_status(&repo, &ignored).unwrap());
assert_eq!(from_git, show(gix_status(&repo, &ignored).unwrap()));
assert!(from_git.0.iter().any(|l| l.starts_with("renamed with space.txt: Some(Renamed)")), "{from_git:?}");
assert_eq!(from_git.1, ["conflict.txt"]);
}
}
-21
View File
@@ -2015,24 +2015,3 @@ fn reword_refuses_commits_outside_the_current_branch() {
fx.git(&["checkout", "-q", "main"]);
assert!(fx.repo().reword(&side, "other").is_err());
}
#[cfg(unix)]
#[test]
fn status_skips_a_directory_it_cannot_read() {
// `git status` warns "could not open directory: Permission denied" and lists the rest.
use std::os::unix::fs::PermissionsExt;
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("init");
fx.write("a.txt", "b\n");
fx.write("new.txt", "n\n");
let locked = fx.path("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("inside.txt"), "x").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let st = fx.repo().status();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
let st = st.unwrap();
assert_eq!(status_of(&st, "a.txt").unstaged, Some(ChangeKind::Modified));
assert_eq!(status_of(&st, "new.txt").unstaged, Some(ChangeKind::Untracked));
}
+1 -79
View File
@@ -359,32 +359,6 @@ fn sync_error_is_tolerable(e: &std::io::Error) -> bool {
false
}
/// On Windows a file can't be replaced while another process has it open without
/// delete sharing: an antivirus or indexer scan, a sync client, a program reading
/// its config. Those let go within moments, so the replacing rename is retried briefly.
const RENAME_ATTEMPTS: u32 = 10;
const RENAME_PAUSE: std::time::Duration = std::time::Duration::from_millis(100);
/// Access denied (os error 5), sharing and lock violations (32, 33) on Windows.
fn held_by_another_process(e: &std::io::Error) -> bool {
cfg!(windows) && matches!(e.raw_os_error(), Some(5 | 32 | 33))
}
fn retrying(
attempts: u32,
pause: std::time::Duration,
transient: impl Fn(&std::io::Error) -> bool,
mut op: impl FnMut() -> std::io::Result<()>,
) -> std::io::Result<()> {
for _ in 1..attempts {
match op() {
Err(e) if transient(&e) => std::thread::sleep(pause),
r => return r,
}
}
op()
}
/// Writes `bytes` to `path` atomically: temp file in the same directory, fsync,
/// rename over the target. Preserves the permissions of an existing file and
/// writes through symlinks. Falls back to an in-place write when the directory
@@ -467,7 +441,7 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool, private: b
fs::set_permissions(&tmp, permissions)?;
}
drop(file);
retrying(RENAME_ATTEMPTS, RENAME_PAUSE, held_by_another_process, || fs::rename(&tmp, &target))
fs::rename(&tmp, &target)
})();
if let Err(e) = result {
let _ = fs::remove_file(&tmp);
@@ -565,58 +539,6 @@ mod tests {
assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o640);
}
#[test]
fn replacing_is_retried_only_while_a_file_is_held() {
use std::io::{Error, ErrorKind};
let in_use = || Error::from_raw_os_error(32);
let transient = |e: &Error| e.raw_os_error() == Some(32);
let zero = std::time::Duration::ZERO;
for (fails, outcome) in [(0, true), (3, true), (RENAME_ATTEMPTS - 1, true), (RENAME_ATTEMPTS, false)] {
let mut calls = 0;
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
calls += 1;
if calls <= fails { Err(in_use()) } else { Ok(()) }
});
assert_eq!(r.is_ok(), outcome, "{fails} failures");
assert_eq!(calls, (fails + 1).min(RENAME_ATTEMPTS));
}
let mut calls = 0;
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
calls += 1;
Err(Error::from(ErrorKind::NotFound))
});
assert_eq!(r.unwrap_err().kind(), ErrorKind::NotFound);
assert_eq!(calls, 1, "other errors fail at once");
for code in [5, 32, 33] {
assert_eq!(held_by_another_process(&Error::from_raw_os_error(code)), cfg!(windows), "os error {code}");
}
assert!(!held_by_another_process(&Error::from(ErrorKind::NotFound)));
}
#[cfg(windows)]
#[test]
fn saving_waits_for_a_windows_file_lock_to_be_released() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
// No sharing at all (os error 32), and readers that don't allow deletion (os error 5).
for share in [0, FILE_SHARE_READ | FILE_SHARE_WRITE] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, b"old").unwrap();
let held = fs::OpenOptions::new().read(true).share_mode(share).open(&path).unwrap();
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(300));
drop(held);
});
let result = write_atomic(&path, b"new");
release.join().unwrap();
result.unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new", "share mode {share}");
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1, "no temp file left behind");
}
}
#[cfg(unix)]
#[test]
fn strict_write_does_not_truncate_when_sibling_creation_is_denied() {
+6 -69
View File
@@ -25,11 +25,6 @@ pub struct DocumentInfo {
pub line_ending: LineEnding,
pub read_only: bool,
pub dirty: bool,
/// Whether "Reload with Encoding" can re-decode this document's source
/// bytes: true for files and virtual documents the backend can re-read
/// (archive entries, JDT sources with an editable attachment encoding,
/// Git revisions); false for server-generated content (decompiled code).
pub can_reload_encoding: bool,
/// Larger than `ide_vfs::LARGE_FILE_THRESHOLD`: language servers, syntax
/// structure and folding are disabled; the editor should drop heavy features.
#[specta(optional)]
@@ -148,9 +143,6 @@ struct DocState {
observed: bool,
binary: bool,
dirty: bool,
can_reload_encoding: bool,
/// The user picked `encoding`; reloads keep it instead of detecting again.
encoding_chosen: bool,
/// Disk state we last loaded or wrote; changes matching it are our own.
disk_mtime: f64,
disk_len: u64,
@@ -347,7 +339,6 @@ impl Documents {
line_ending: st.doc.line_ending(),
read_only: st.read_only,
dirty: st.dirty,
can_reload_encoding: st.can_reload_encoding,
large: st.large,
editorconfig: st.editorconfig.clone(),
uri: st.uri.clone(),
@@ -421,8 +412,6 @@ impl Documents {
observed,
binary: loaded.binary,
dirty: false,
can_reload_encoding: true,
encoding_chosen: false,
disk_mtime: loaded.mtime_ms,
disk_len: disk_len(path),
closed: false,
@@ -460,19 +449,8 @@ impl Documents {
/// Opens (or re-references) a read-only virtual document: contents a
/// language server provides for a non-`file:` URI. It is keyed by the URI,
/// never touches the disk and cannot be edited or saved. `encoding` /
/// `has_bom` describe how the content was decoded (not a guess);
/// `can_reload_encoding` tells whether [`Documents::replace_virtual`] can
/// re-decode it ("Reload with Encoding").
pub fn open_virtual(
&self,
uri: &str,
language: LanguageId,
text: &str,
encoding: &str,
has_bom: bool,
can_reload_encoding: bool,
) -> IdeResult<DocumentInfo> {
/// never touches the disk and cannot be edited or saved.
pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult<DocumentInfo> {
let _operation = self.operations.lock();
let key = PathBuf::from(uri);
if let Some(info) = self.reopen(&key) {
@@ -484,8 +462,8 @@ impl Documents {
doc: Document::new(text),
language,
ui_mode: None,
encoding: encoding.into(),
has_bom,
encoding: "utf-8".into(),
has_bom: false,
native_to_ascii: false,
read_only: true,
large,
@@ -493,8 +471,6 @@ impl Documents {
observed: true,
binary: false,
dirty: false,
can_reload_encoding,
encoding_chosen: false,
disk_mtime: 0.0,
disk_len: 0,
closed: false,
@@ -870,7 +846,6 @@ impl Documents {
let loaded = Self::load_as(&st.path, encoding, enabled)?;
st.native_to_ascii = enabled;
st.encoding = loaded.encoding.clone();
st.encoding_chosen = true;
st.has_bom = loaded.has_bom;
st.disk_mtime = loaded.mtime_ms;
st.disk_len = disk_len(&st.path);
@@ -889,45 +864,12 @@ impl Documents {
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
}
st.encoding = encoding.to_owned();
st.encoding_chosen = true;
st.has_bom = has_bom;
self.set_dirty(doc, st, true);
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
})
}
/// The URI of a virtual document, or `None` for a file-backed one.
pub fn virtual_uri(&self, doc: DocId) -> IdeResult<Option<String>> {
self.with_doc(doc, |st| Ok(st.uri.clone()))
}
/// Re-decoded content of a virtual document ("Reload with Encoding"):
/// replaces the text and the encoding label, keeping the document
/// read-only and clean. The caller re-reads the origin (archive entry,
/// language server, Git blob) with the chosen encoding first.
pub fn replace_virtual(
&self,
doc: DocId,
text: &str,
encoding: &str,
has_bom: bool,
) -> IdeResult<DocumentEncoding> {
self.with_doc(doc, |st| {
if st.uri.is_none() {
return Err(IdeError::invalid("not a virtual document"));
}
ide_vfs::encode("", encoding, false)?;
st.doc.set_line_ending(LineEnding::detect(text));
self.replace_locked(doc, st, text)?;
st.encoding = encoding.to_owned();
st.has_bom = has_bom;
st.large = text.len() as u64 > ide_vfs::LARGE_FILE_THRESHOLD;
// Reloading is not an edit: the buffer matches its source again.
self.set_dirty(doc, st, false);
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
})
}
/// Adds or removes the BOM on a UTF-8 document; the change is written on save.
pub fn set_bom(&self, doc: DocId, has_bom: bool) -> IdeResult<DocumentEncoding> {
self.with_doc(doc, |st| {
@@ -1026,13 +968,8 @@ impl Documents {
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
} else {
let defaults = self.file_defaults();
let native_to_ascii = defaults.native_to_ascii_for(&st.path);
let loaded = if st.encoding_chosen {
Self::load_as(&st.path, &st.encoding, native_to_ascii)?
} else {
Self::load_with_defaults(&st.path, &defaults)?
};
st.native_to_ascii = native_to_ascii;
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
loaded
};
st.disk_mtime = loaded.mtime_ms;
@@ -748,7 +748,6 @@ impl Documents {
let result = self.with_doc(*id, |state| {
self.replace_locked(*id, state, &before.doc.text())?;
state.encoding = before.encoding.clone();
state.encoding_chosen = before.encoding_chosen;
state.has_bom = before.has_bom;
state.doc.set_line_ending(before.doc.line_ending());
state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0);
@@ -790,11 +789,7 @@ impl Documents {
return Err(stale(path));
}
let defaults = self.file_defaults();
let loaded = if state.encoding_chosen {
Self::load_as(path, &state.encoding, state.native_to_ascii)?
} else {
ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?
};
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?;
self.replace_locked(id, state, &loaded.text)?;
state.encoding = loaded.encoding;
state.has_bom = loaded.has_bom;
+2 -106
View File
@@ -505,48 +505,6 @@ fn reload_and_convert_encoding() {
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err());
}
#[test]
fn external_changes_keep_a_chosen_encoding() {
// Valid UTF-8 bytes, so detection alone would read them as UTF-8.
let (first, changed) = (b"caf\xC3\xA9".as_slice(), b"caf\xC3\xA9 \xC3\xA8".as_slice());
for (encoding, reload) in [("windows-1252", true), ("gbk", true), ("windows-1252", false)] {
let f = fixture();
let path = f.root.join("legacy.txt");
fs::write(&path, first).unwrap();
let info = f.docs.open(&path).unwrap();
assert_eq!(info.encoding, "utf-8");
if reload {
f.docs.set_encoding(info.id, encoding, true).unwrap();
} else {
// Convert, then save: the file is now in the chosen encoding.
f.docs.replace_all(info.id, "café").unwrap();
f.docs.set_encoding(info.id, encoding, false).unwrap();
f.docs.save(info.id).unwrap();
}
let expected =
|bytes: &[u8]| encoding_rs::Encoding::for_label(encoding.as_bytes()).unwrap().decode(bytes).0.into_owned();
let changed = if reload { changed.to_vec() } else { ide_vfs::encode("café è", encoding, false).unwrap() };
fs::write(&path, &changed).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snapshot = f.docs.snapshot(info.id).unwrap();
assert_eq!(snapshot.text, expected(&changed), "{encoding} reload={reload}");
assert_eq!(snapshot.encoding, encoding, "{encoding} reload={reload}");
assert!(!snapshot.dirty);
}
}
#[test]
fn external_changes_redetect_an_encoding_the_user_never_chose() {
let f = fixture();
let path = f.root.join("plain.txt");
fs::write(&path, "plain").unwrap();
let info = f.docs.open(&path).unwrap();
fs::write(&path, ide_vfs::encode("wide", "utf-16le", true).unwrap()).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snapshot = f.docs.snapshot(info.id).unwrap();
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("wide", "utf-16le"));
}
#[test]
fn utf8_bom_is_added_and_removed_on_save() {
let f = fixture();
@@ -698,14 +656,14 @@ fn editorconfig_applies_on_open_and_save() {
fn virtual_documents_are_read_only_and_keyed_by_uri() {
let f = fixture();
let uri = "jdt://contents/a.jar/p/C.class?=x";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n", "utf-8", false, true).unwrap();
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n").unwrap();
assert_eq!(info.uri.as_deref(), Some(uri));
assert_eq!(info.path, PathBuf::from(uri));
assert!(info.read_only && !info.dirty);
assert_eq!(info.language.as_str(), "java");
// Re-opening shares the document; edits and saves never reach a file.
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored", "utf-8", false, true).unwrap();
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored").unwrap();
assert_eq!(again.id, info.id);
assert_eq!(again.text, "class C {}\n");
let err = f.docs.edit(info.id, info.version, change(0, 0, "x")).unwrap_err();
@@ -719,68 +677,6 @@ fn virtual_documents_are_read_only_and_keyed_by_uri() {
assert_eq!(*f.rec.log.lock(), vec!["open v0 java".to_owned(), "close".to_owned()]);
}
#[test]
fn virtual_documents_carry_their_encoding_and_reload_capability() {
let f = fixture();
// Archive sources decoded by the backend report the charset actually used.
let uri = "jar:///m2/lib-1.0-sources.jar!/p/C.java";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "中文", "gbk", false, true).unwrap();
assert_eq!((info.encoding.as_str(), info.has_bom, info.can_reload_encoding), ("gbk", false, true));
let bom =
f.docs.open_virtual("jar:///m2/b.jar!/p/B.java", LanguageId::new("java"), "x", "utf-8", true, true).unwrap();
assert!(bom.has_bom);
// Server-generated content (decompiled) cannot be re-decoded.
let decompiled = f
.docs
.open_virtual(
"csharp:/p/App.csproj/decompiled/Lib.cs",
LanguageId::new("csharp"),
"class Lib {}",
"utf-8",
false,
false,
)
.unwrap();
assert!(!decompiled.can_reload_encoding);
// File-backed documents can always be re-read from disk.
let path = f.root.join("f.txt");
fs::write(&path, "x").unwrap();
assert!(f.docs.open(&path).unwrap().can_reload_encoding);
}
#[test]
fn virtual_documents_reload_with_another_encoding_without_becoming_editable() {
let f = fixture();
let uri = "jdt://contents/a.jar/p/C.class?=x";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "涓枃", "utf-8", false, true).unwrap();
let encoding = f.docs.replace_virtual(info.id, "中文", "gbk", false).unwrap();
assert_eq!((encoding.encoding.as_str(), encoding.has_bom), ("gbk", false));
let snap = f.docs.snapshot(info.id).unwrap();
assert_eq!(snap.text, "中文");
assert_eq!(snap.encoding, "gbk");
assert!(snap.read_only && !snap.dirty, "reloading is not an edit");
assert!(snap.version > info.version);
assert_eq!(f.docs.virtual_uri(info.id).unwrap().as_deref(), Some(uri));
// The replacement reached observers and the event stream (editors resync).
assert!(f.rec.log.lock().iter().any(|l| l.starts_with("change v") && l.contains("中文")));
assert!(f.events.lock().iter().any(|e| matches!(e, IdeEvent::DocPatched { doc, .. } if *doc == info.id)));
// Read-only protection is unchanged.
assert_eq!(f.docs.edit(info.id, snap.version, change(0, 0, "x")).unwrap_err().kind, ErrorKind::PermissionDenied);
assert!(f.docs.set_encoding(info.id, "utf-8", false).is_err());
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err(), "the op layer routes virtual reloads");
assert!(f.docs.set_bom(info.id, true).is_err());
f.docs.save(info.id).unwrap();
// Unknown encodings are refused; file-backed documents are not virtual.
assert!(f.docs.replace_virtual(info.id, "x", "not-an-encoding", false).is_err());
let path = f.root.join("f.txt");
fs::write(&path, "x").unwrap();
let file = f.docs.open(&path).unwrap();
assert!(f.docs.replace_virtual(file.id, "y", "utf-8", false).is_err());
assert_eq!(f.docs.virtual_uri(file.id).unwrap(), None);
}
#[test]
fn toggling_the_writable_bit_updates_the_open_document() {
let f = fixture();
@@ -162,21 +162,6 @@ fn undo_id(events: &Mutex<Vec<IdeEvent>>) -> u32 {
.expect("successful transaction has one undo record")
}
#[test]
fn undo_keeps_the_encoding_chosen_for_a_file_opened_after_the_edit() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("legacy.txt");
std::fs::write(&path, b"abc\xC3\xA9").unwrap();
let (docs, events) = recorded_documents();
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap();
let info = docs.open(&path).unwrap();
docs.set_encoding(info.id, "windows-1252", true).unwrap();
assert_eq!(docs.snapshot(info.id).unwrap().text, "newé");
docs.undo_workspace_edit(undo_id(&events)).unwrap();
let snapshot = docs.snapshot(info.id).unwrap();
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("abcé", "windows-1252"));
}
#[test]
fn stale_document_version_rejects_the_whole_edit() {
let tmp = tempfile::tempdir().unwrap();
@@ -530,18 +515,6 @@ fn duplicate_edits_keep_versions_and_apply_one_replacement() {
assert_eq!(std::fs::read_to_string(path).unwrap(), "new_long");
}
#[test]
fn repeated_inserts_at_one_position_all_apply_once_per_entry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("main.ts");
std::fs::write(&path, "f(").unwrap();
let docs = documents();
let close = TextEdit { range: Range::new(Pos::new(0, 2), Pos::new(0, 2)), new_text: ")".into() };
let entry = FileEdit::Edit { path: path.clone().into(), version: None, edits: vec![close.clone(), close] };
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![entry.clone(), entry], ..Default::default() }).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "f())");
}
#[test]
fn undo_accepts_typing_that_was_reverted_to_the_saved_refactoring() {
let tmp = tempfile::tempdir().unwrap();
-2
View File
@@ -28,8 +28,6 @@ You can change single shortcuts on top of any keymap, or duplicate one into your
The defaults already match IDEA: the keymap, tool window numbers (`⌘1` / `Alt+1` for Project, `⌘9` / `Alt+9` for Git), `⇧⇧` / `Shift Shift` for Search Everywhere and `⌥↵` / `Alt+Enter` for context actions.
A tool window's options menu (`⋮`) has IDEA's **View Mode**: **Dock Pinned** (the default); **Dock Unpinned**, which hides the window when you click into the editor or another tool window; and **Undock**, which shows the window over the editor without resizing it and hides it the same way. Float and Window are not available.
### Import settings
Use **File › Manage IDE Settings › Import Settings from JetBrains IDE…** and pick one of the JetBrains IDEs found on this computer. To use a `settings.zip` exported from a JetBrains IDE instead, choose **File › Manage IDE Settings › Import Settings…** and select the zip.
+1 -1
View File
@@ -133,7 +133,7 @@ Opening a folder under `\\wsl.localhost\<distribution>\` also opens it through t
- **Run targets** (**Settings › Build, Execution, Deployment › Run Targets**) run a configuration on an SSH host. See
[Run configurations](/en/guide/run).
- **Data sources** can connect through an SSH tunnel. See [Database](/en/guide/database).
- **Tools › Deployment** uploads, downloads and syncs project files with a server over SFTP, FTP or FTPS.
- **Tools › Deployment** uploads, downloads and syncs project files with a server over SFTP.
## Limits
+4 -6
View File
@@ -11,10 +11,9 @@ Each open file gets its language from the definitions, checked in this order:
1. A per-file **Override File Type** (in the context menu of a file in the project tree or of an editor tab).
2. An exact file name (`Dockerfile`, `go.mod`) or a file name pattern.
3. A framework's folder: an `.html` or `.txt` file in a `templates` folder with a `manage.py` above it is a Django template.
4. The extension, longest match first (`.html.erb` before `.erb`).
5. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
6. Otherwise the file is plain text.
3. The extension, longest match first (`.html.erb` before `.erb`).
4. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
5. Otherwise the file is plain text.
You can add or remove file name patterns per language in **Settings › Editor › File Types** (**Recognized File Types**).
@@ -107,7 +106,6 @@ The table lists every built-in language definition. Server and debugger names in
| D | `d` | `.d`, `.di` | Yes | — | serve-d | — |
| Dart | `dart` | `.dart` | Yes | Yes | dart | dart |
| Diff | `diff` | `.diff`, `.patch`, `.rej` | Yes | — | — | — |
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | Yes | Yes | **vscode-html-language-server** | — |
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | Yes | — | **dockerfile-language-server** | — |
| Elixir | `elixir` | `.ex`, `.exs` | Yes | Yes | **elixir-ls** | **elixir-ls-debugger** |
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | Yes | — | — | — |
@@ -135,7 +133,7 @@ The table lists every built-in language definition. Server and debugger names in
| JSON with Comments | `jsonc` | `.jsonc`, `.code-workspace`, `.code-snippets` | Yes | Yes | **vscode-json-language-server** | — |
| JSP | `jsp` | `.jsp`, `.jspx`, `.jspf`, `.jsph`, `.tag`, … | Yes | — | — | — |
| Julia | `julia` | `.jl` | Yes | — | julia-languageserver | — |
| Kotlin | `kotlin` | `.kt`, `.kts` | Yes | Yes | **kotlin-lsp** → **kotlin-language-server** → **kmp-lsp** | **kotlin-debug-adapter** |
| Kotlin | `kotlin` | `.kt`, `.kts` | Yes | Yes | **kotlin-lsp** → **kotlin-language-server** | **kotlin-debug-adapter** |
| LaTeX | `latex` | `.tex`, `.ltx`, `.sty`, `.cls`, `.dtx`, … | Yes | — | **texlab** | — |
| Less | `less` | `.less` | Yes | — | **vscode-css-language-server** | — |
| Log | `log` | `.log` | Yes | — | — | — |
+6 -6
View File
@@ -7,12 +7,12 @@ Java code insight comes from the Eclipse JDT Language Server (jdtls), extended w
## Language servers
| Language | Server | Fallback | Debugger |
| ---------------------- | ----------------------------------------- | ------------------------------------------------------------------ | --------------------------------------- |
| Java | Eclipse JDT Language Server (`jdtls`) | `kmp-lsp` | Microsoft java-debug, loaded into jdtls |
| Kotlin | Kotlin LSP (JetBrains, `kotlin-lsp`) | Kotlin Language Server (fwcd, `kotlin-language-server`), `kmp-lsp` | Kotlin Debug Adapter (fwcd) |
| Groovy, Gradle scripts | `groovy-language-server` (you provide it) | | |
| JSP | none (highlighting only) | | |
| Language | Server | Fallback | Debugger |
| ---------------------- | ----------------------------------------- | ------------------------------------------------------- | --------------------------------------- |
| Java | Eclipse JDT Language Server (`jdtls`) | `kmp-lsp` | Microsoft java-debug, loaded into jdtls |
| Kotlin | Kotlin LSP (JetBrains, `kotlin-lsp`) | Kotlin Language Server (fwcd, `kotlin-language-server`) | Kotlin Debug Adapter (fwcd) |
| Groovy, Gradle scripts | `groovy-language-server` (you provide it) | | |
| JSP | none (highlighting only) | | |
A fallback starts only while the server listed before it is not installed. To pick one explicitly, open **Settings › Languages & Frameworks › Language Servers**, select either server and use **Language server for Java** (or Kotlin): **Automatic** uses the first one available, **None** turns code insight off. The choice can be global or for the current project.
-6
View File
@@ -125,12 +125,6 @@ See [Debugging](/en/guide/debugging) for breakpoints, stepping and the debugger
Django is the only framework with dedicated support: the `manage.py runserver` and `manage.py test` configurations, the **Django tests** configuration type (target, settings module, options), running single `TestCase` tests from the gutter, and Django mode in the debugger. Other frameworks (Flask, FastAPI and so on) run as ordinary scripts or console scripts.
### Django templates
The HTML and text files in a `templates` folder of a Django project (one with a `manage.py` above it) open as **Django Template** files: the template tags, variables, filters and comments are highlighted on top of the HTML, and block tags (`{% block %}`, `{% if %}`, `{% for %}`, `{% with %}` and the others of Django and its contrib libraries) fold, indent their content and highlight their end tag. Typing `{%`, `{{` or `{#` closes the pair, finishing a block tag adds its end tag, and `{% end` completes the end tags of the open blocks. **Comment with Line Comment** puts each line in its own `{# … #}`, and **Comment with Block Comment** over several lines uses `{% comment %}`. The HTML language server and Emmet work in the markup between the tags.
An HTML file elsewhere with Django tags shows a banner that switches it to Django Template or keeps it as HTML; either choice is the file's **Override File Type**.
## Limitations
- Only one type checker runs at a time. Ruff runs only in projects with a Ruff configuration.
+1
View File
@@ -52,6 +52,7 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
| Editor | General (Auto Import, Appearance, Breadcrumbs, Code Completion, Code Folding, Editor Tabs, Gutter Icons, Inline Completion, Postfix Completion, Rich-Text Copy, Smart Keys, Sticky Lines); Code Editing; Font; Color Scheme; Code Style; Inspections; Natural Languages (Spelling); File and Code Templates; File Encodings; Live Templates; File Types; Inlay Hints; TODO; Vim |
| Plugins | Plugins |
| Version Control | Commit; Confirmation; Directory Mappings; Issue Navigation; Log; Shelf; Git; GitHub |
| Project Structure | Project Structure |
| Build, Execution, Deployment | CMake; Gradle; Maven; Console; Debugger (Data Views, Stepping); Python Debugger; Run/Debug; Run Targets; Deployment (Options) |
| Languages & Frameworks | Language Servers; Go (Build Tags, Go Modules); Java; Markdown; Node.js; PHP; Python Interpreter; Prettier; oxfmt; Ruff; Ruby; Rust; TypeScript |
| Tools | Agents (Chat); Terminal; Actions on Save; External Tools; SSH Configurations; MCP Server |
-2
View File
@@ -28,8 +28,6 @@ OpenIDEA 内置六套快捷键方案:
默认设置已经和 IDEA 一致:快捷键方案、工具窗口编号(`⌘1` / `Alt+1` 打开项目,`⌘9` / `Alt+9` 打开 Git)、`⇧⇧` / `Shift Shift` 打开随处搜索、`⌥↵` / `Alt+Enter` 显示上下文操作。
工具窗口的选项菜单(`⋮`)提供 IDEA 的**视图模式**:**停靠固定**(默认);**停靠取消固定**,点击编辑器或其他工具窗口时隐藏该窗口;**取消停靠**,窗口显示在编辑器上方,不改变编辑器大小,并以同样方式隐藏。不提供“浮动”和“窗口”模式。
### 导入设置
使用 **文件 › 管理 IDE 设置 › 从 JetBrains IDE 导入设置…**,从本机找到的 JetBrains IDE 中选择一个。如果手上是从 JetBrains IDE 导出的 `settings.zip`,则选择 **文件 › 管理 IDE 设置 › 导入设置…** 并选中该 zip 文件。
+1 -1
View File
@@ -105,7 +105,7 @@ OpenIDEA 自带 SSH 客户端,自行读取 `~/.ssh/config`,支持 `Host`、`
- **运行目标**(**设置 › 构建、执行、部署 › 运行目标**)可以在 SSH 主机上执行运行配置,见[运行配置](/zh/guide/run)。
- **数据源**可以通过 SSH 隧道连接,见[数据库](/zh/guide/database)。
- **工具 › 部署**通过 SFTP、FTP 或 FTPS 与服务器上传、下载和同步项目文件。
- **工具 › 部署**通过 SFTP 与服务器上传、下载和同步项目文件。
## 限制
+4 -6
View File
@@ -11,10 +11,9 @@ OpenIDEA 用一个小的定义文件 `languages/<id>.toml` 描述每种语言:
1. 单个文件的 **覆盖文件类型**(在项目树中文件的右键菜单或编辑器标签页的右键菜单里)。
2. 精确文件名(`Dockerfile`、`go.mod`)或文件名模式。
3. 框架目录:上层目录有 `manage.py` 的 `templates` 目录中的 `.html` 或 `.txt` 文件是 Django 模板。
4. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
5. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
6. 都不匹配时按纯文本处理。
3. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
4. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
5. 都不匹配时按纯文本处理。
在 **设置 › 编辑器 › 文件类型**(**已识别的文件类型**)中可以为某种语言添加或移除文件名模式。
@@ -102,7 +101,6 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
| D | `d` | `.d`, `.di` | 是 | — | serve-d | — |
| Dart | `dart` | `.dart` | 是 | 是 | dart | dart |
| Diff | `diff` | `.diff`, `.patch`, `.rej` | 是 | — | — | — |
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | 是 | 是 | **vscode-html-language-server** | — |
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | 是 | — | **dockerfile-language-server** | — |
| Elixir | `elixir` | `.ex`, `.exs` | 是 | 是 | **elixir-ls** | **elixir-ls-debugger** |
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | 是 | — | — | — |
@@ -130,7 +128,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
| JSON with Comments | `jsonc` | `.jsonc`, `.code-workspace`, `.code-snippets` | 是 | 是 | **vscode-json-language-server** | — |
| JSP | `jsp` | `.jsp`, `.jspx`, `.jspf`, `.jsph`, `.tag`, … | 是 | — | — | — |
| Julia | `julia` | `.jl` | 是 | — | julia-languageserver | — |
| Kotlin | `kotlin` | `.kt`, `.kts` | 是 | 是 | **kotlin-lsp** → **kotlin-language-server** → **kmp-lsp** | **kotlin-debug-adapter** |
| Kotlin | `kotlin` | `.kt`, `.kts` | 是 | 是 | **kotlin-lsp** → **kotlin-language-server** | **kotlin-debug-adapter** |
| LaTeX | `latex` | `.tex`, `.ltx`, `.sty`, `.cls`, `.dtx`, … | 是 | — | **texlab** | — |
| Less | `less` | `.less` | 是 | — | **vscode-css-language-server** | — |
| Log | `log` | `.log` | 是 | — | — | — |
+6 -6
View File
@@ -7,12 +7,12 @@ Java 的代码智能由 Eclipse JDT Language Server(jdtls)提供,OpenIDEA
## 语言服务器
| 语言 | 服务器 | 备选 | 调试器 |
| ------------------- | -------------------------------------- | ------------------------------------------------------------------- | ------------------------------------- |
| Java | Eclipse JDT Language Server(`jdtls`) | `kmp-lsp` | Microsoft java-debug,加载在 jdtls 内 |
| Kotlin | Kotlin LSP(JetBrains,`kotlin-lsp`) | Kotlin Language Server(fwcd,`kotlin-language-server`)、`kmp-lsp` | Kotlin Debug Adapter(fwcd) |
| Groovy、Gradle 脚本 | `groovy-language-server`(需自行提供) | | |
| JSP | 无(仅语法高亮) | | |
| 语言 | 服务器 | 备选 | 调试器 |
| ------------------- | -------------------------------------- | -------------------------------------------------------- | ------------------------------------- |
| Java | Eclipse JDT Language Server(`jdtls`) | `kmp-lsp` | Microsoft java-debug,加载在 jdtls 内 |
| Kotlin | Kotlin LSP(JetBrains,`kotlin-lsp`) | Kotlin Language Server(fwcd,`kotlin-language-server`) | Kotlin Debug Adapter(fwcd) |
| Groovy、Gradle 脚本 | `groovy-language-server`(需自行提供) | | |
| JSP | 无(仅语法高亮) | | |
备选服务器只在排在它前面的服务器未安装时才会启动。想手动指定,打开 **设置 › 语言和框架 › 语言服务器**,选中其中一个服务器,在 **Java 语言服务器**(或 Kotlin 语言服务器)里选择:**自动** 使用第一个可用的,**无** 关闭代码智能。这个选择可以全局生效,也可以只对当前项目生效。
-6
View File
@@ -125,12 +125,6 @@ Python 调试使用 debugpy。开始调试时如果缺少 debugpy,OpenIDEA 会
目前只有 Django 有专门支持:`manage.py runserver` 和 `manage.py test` 运行配置、**Django 测试** 配置类型(目标、settings 模块、选项)、在左侧栏运行单个 `TestCase` 测试,以及调试器的 Django 模式。Flask、FastAPI 等其他框架按普通脚本或命令行入口运行。
### Django 模板
Django 项目(上层目录有 `manage.py`)中 `templates` 目录里的 HTML 和文本文件以 **Django Template** 打开:模板标签、变量、过滤器和注释在 HTML 之上高亮,块标签(`{% block %}`、`{% if %}`、`{% for %}`、`{% with %}` 以及 Django 和其 contrib 库的其他块标签)可以折叠、缩进内容并高亮对应的结束标签。输入 `{%`、`{{` 或 `{#` 会自动补全成对符号,写完块标签会自动添加结束标签,输入 `{% end` 会补全当前打开块的结束标签。**使用行注释** 会把每一行放进各自的 `{# … #}`,多行的 **使用块注释** 则用 `{% comment %}`。HTML 语言服务器和 Emmet 在标签之间的标记中照常工作。
其他位置含有 Django 标签的 HTML 文件会显示一个横幅,可以切换为 Django Template 或保留为 HTML;两种选择都会成为该文件的 **覆盖文件类型**。
## 限制
- 同一时间只运行一个类型检查器。Ruff 只在有 Ruff 配置的项目中运行。
+1
View File
@@ -52,6 +52,7 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
| 编辑器 | 常规(自动导入、外观、痕迹导航、代码补全、代码折叠、编辑器标签页、装订区域图标、内联补全、后缀补全、富文本复制、智能键、粘滞行);代码编辑;字体;配色方案;代码样式;检查;自然语言(拼写);文件和代码模板;文件编码;实时模板;文件类型;嵌入提示;TODO;Vim |
| 插件 | 插件 |
| 版本控制 | 提交;确认;目录映射;问题导航;日志;搁置;Git;GitHub |
| 项目结构 | 项目结构 |
| 构建、执行、部署 | CMake;Gradle;Maven;控制台;调试器(数据视图、单步执行);Python 调试器;运行/调试;运行目标;部署(选项) |
| 语言和框架 | 语言服务器;Go(构建标签、Go 模块);Java;Markdown;Node.js;PHP;Python 解释器;Prettier;oxfmt;Ruff;Ruby;Rust;TypeScript |
| 工具 | 智能体(聊天);终端;保存时操作;外部工具;SSH 配置;MCP 服务器 |
-8
View File
@@ -383,14 +383,6 @@ test("keeps mermaid `<style>` rules scoped to the rendered svg after id rewritin
expect(await css(reply, ".flowchart-link", "fill")).toBe("none");
});
test("renders KaTeX math in mermaid labels", async ({ page }) => {
const diagram = ["```mermaid", "flowchart LR", ' A["$$x^2 + \\frac{a}{b}$$"] --> B', "```"].join("\n");
const reply = await openReply(page, `Diagram:\n\n${diagram}`);
const svg = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg");
await expect(svg).toBeVisible();
await expect(svg.locator("math mfrac")).toHaveCount(1);
});
test("keeps mermaid flowchart nodes and sequence control blocks off solid black", async ({ page }) => {
const flow = [
"```mermaid",
+8 -5
View File
@@ -85,13 +85,16 @@ for (const id of ["editor.fileTemplates", "editor.liveTemplates"]) {
}
test("Project Structure keeps every details pane usable in a compact window", async ({ workbench: page }) => {
await page.keyboard.press("Meta+;");
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
await expect(dialog).toBeVisible();
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage("projectStructure");
});
await page.setViewportSize({ width: 520, height: 760 });
for (const section of ["Modules", "Libraries", "Facets", "Artifacts", "SDKs"]) {
await dialog.getByRole("button", { name: section, exact: true }).click();
const details = dialog.locator("[class*='splitLayout'] > [class*='detailPane']");
await page.getByRole("tab", { name: section, exact: true }).click();
const details = page.locator("[class*='splitLayout'] > [class*='detailPane']");
await expect(details).toBeAttached();
expect((await details.boundingBox())!.width).toBeGreaterThan(200);
}
-30
View File
@@ -93,36 +93,6 @@ test("terminal highlights visible occurrences of selected text in a single overl
activeSession()!.term.clearSelection();
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(0);
// Full-scrollback trim shifts a visible match into the cached margin row above viewportY; scrolling up must highlight it.
await page.evaluate(async () => {
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
const s = activeSession()!;
const rows = s.term.rows;
s.term.options.scrollback = 4;
s.term.reset();
const total = rows + 4;
let payload = "";
for (let i = 0; i < total - 1; i++) {
payload += `${i === 4 ? "needle hit" : `row-${i}`}\r\n`;
}
payload += "needle sel";
await new Promise<void>((resolve) => s.term.write(payload, resolve));
s.term.select(0, total - 1, 6);
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 100));
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
const s = activeSession()!;
await new Promise<void>((resolve) => s.term.write("\r\nnext", resolve));
s.term.scrollLines(-1);
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCSS("top", "0px");
});
test("terminal ⌘F highlights active/inactive search matches and seeds with a new selection", async ({
-75
View File
@@ -1,75 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, openFromTree, test } from "./fixtures";
const project = (page: Page) => page.locator('[data-toolwindow="project"]');
const editorArea = (page: Page) => page.locator("[data-editor-area]");
async function setViewMode(page: Page, mode: "Dock Pinned" | "Dock Unpinned" | "Undock") {
await project(page).getByRole("button", { name: "Options" }).click();
await page.getByRole("menuitem", { name: "View Mode" }).click();
await page.getByRole("menuitemcheckbox", { name: mode }).click();
}
test("Undock shows the Project window over the editor; focusing the editor hides it", async ({ workbench: page }) => {
const editor = await openFromTree(page, "build.gradle.kts");
const docked = (await editorArea(page).boundingBox())!;
await setViewMode(page, "Undock");
await expect(project(page)).toBeVisible();
const area = (await editorArea(page).boundingBox())!;
// The editor takes the Project window's place and keeps it while the window shows over it.
expect(area.x).toBeLessThan(docked.x);
expect(area.width).toBeGreaterThan(docked.width);
const over = (await project(page).boundingBox())!;
expect(over.x + over.width).toBeGreaterThan(area.x + 100);
// Its inner edge resizes it over the editor.
const edge = (await page.getByRole("separator", { name: "Resize undocked left tool window" }).boundingBox())!;
await page.mouse.move(edge.x + edge.width / 2, edge.y + edge.height / 2);
await page.mouse.down();
await page.mouse.move(edge.x + edge.width / 2 + 100, edge.y + edge.height / 2, { steps: 5 });
await page.mouse.up();
expect((await project(page).boundingBox())!.width).toBeCloseTo(over.width + 100, -1);
expect(await editorArea(page).boundingBox()).toEqual(area);
// Right of the window, which covers the editor's left part.
const content = editor.locator(".cm-content");
await content.click({ position: { x: (await content.boundingBox())!.width * 0.75, y: 40 } });
await expect(project(page)).toBeHidden();
expect(await editorArea(page).boundingBox()).toEqual(area);
await page.keyboard.press("Meta+1");
await expect(project(page)).toBeVisible();
await expect(page.getByRole("tree", { name: "Project" })).toBeFocused();
expect(await editorArea(page).boundingBox()).toEqual(area);
});
test("a menu opened from an undocked window leaves it open", async ({ workbench: page }) => {
await setViewMode(page, "Undock");
await project(page).getByRole("button", { name: "Options" }).click();
await expect(page.getByRole("menuitem", { name: "View Mode" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(project(page)).toBeVisible();
await page.getByRole("treeitem", { name: /acme-api/ }).click({ button: "right" });
await page.keyboard.press("Escape");
await expect(project(page)).toBeVisible();
});
test("Dock Unpinned docks the window and hides it when another tool window takes focus", async ({
workbench: page,
}) => {
const docked = (await editorArea(page).boundingBox())!;
await setViewMode(page, "Dock Unpinned");
expect(await editorArea(page).boundingBox()).toEqual(docked);
await page.getByRole("tree", { name: "Project" }).focus();
await page.keyboard.press("Meta+6");
await expect(page.locator('[data-toolwindow="problems"]')).toBeVisible();
await expect(project(page)).toBeHidden();
// Back to Dock Pinned: the window stays when focus moves on.
await page.keyboard.press("Meta+1");
await setViewMode(page, "Dock Pinned");
await page.keyboard.press("Meta+6");
await expect(project(page)).toBeVisible();
});
-1
View File
@@ -15,7 +15,6 @@ something to show.
| `java-app/` | JDK 21, Maven 3.9 | `mvn compile exec:java` | `mvn test` | `mvn test -DopenideaFail=true` | unused variable in `App.java` |
| `c-app/` | gcc / g++ 12+, make, GDB 14+ | `make all` | — | — | — |
| `php-app/` | PHP 8.5, Composer | `composer quote` | `vendor/bin/pest` | `OPENIDEA_FAIL=1 vendor/bin/pest` | unused variable in `ThresholdCustoms.php` |
| `django-app/` | Python 3.10+, Django 5.2+ | `python manage.py runserver` | `python manage.py test` | `OPENIDEA_FAIL=1 python manage.py test` | unused import in `shop/views.py` |
| `polyglot/` | Go, TS, Python, C, shell, SQL, TOML/YAML/JSON, proto, Terraform | `make` | `make test` | — | unused variable in `native/hash.c` |
The fixtures are standalone: `rust-app` declares its own `[workspace]` and
Whitespace-only changes.
-27
View File
@@ -1,27 +0,0 @@
"""Settings of the shop fixture: one app, no database tables of its own."""
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "fixture-only-not-a-secret"
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
INSTALLED_APPS = ["django.contrib.staticfiles", "shop"]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {"context_processors": ["django.template.context_processors.request"]},
}
]
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
STATIC_URL = "static/"
USE_TZ = True
-3
View File
@@ -1,3 +0,0 @@
from django.urls import include, path
urlpatterns = [path("", include("shop.urls"))]
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for the shop fixture."""
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
-4
View File
@@ -1,4 +0,0 @@
<!-- Not in a templates folder: the editor offers Django Template from its tags. -->
{% load static %}
<p>Two cappuccini for <strong>{{ price|default:"€3" }}</strong></p>
<img src="{% static 'promo.png' %}" alt="">
-1
View File
@@ -1 +0,0 @@
Django>=5.2
Whitespace-only changes.
@@ -1,15 +0,0 @@
{% load static %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Shop{% endblock %}</title>
<link rel="stylesheet" href="{% static 'shop/site.css' %}">
</head>
<body>
{# The navigation is the same on every page. #}
<nav><a href="{% url 'shop:products' %}">Menu</a></nav>
<main>
{% block content %}{% endblock content %}
</main>
</body>
</html>
@@ -1,3 +0,0 @@
{% autoescape off %}Thank you for your order, {{ name|default:"customer" }}!
{% for line in lines %}- {{ line.quantity }} × {{ line.product.name }}
{% endfor %}{% endautoescape %}
@@ -1,25 +0,0 @@
{% extends "shop/base.html" %}
{% block title %}{% if single %}{{ products.0.name }}{% else %}Menu{% endif %} · {{ block.super }}{% endblock %}
{% block content %}
<ul class="products">
{% for product in products %}
<li class="{% cycle 'odd' 'even' %}">
<a href="{% url 'shop:product' slug=product.slug %}">{{ product.name|title }}</a>
<span class="price">€{{ product.price|floatformat:2 }}</span>
{% with count=product.tags|length %}
{% if count > 1 %}
<small>{{ product.tags|join:", " }}</small>
{% endif %}
{% endwith %}
</li>
{% empty %}
<li>Nothing on the menu today.</li>
{% endfor %}
</ul>
{% comment "Ideas" %}
<p>{{ seasonal_specials }}</p>
{% endcomment %}
{% verbatim %}<script type="text/x-template">{{ clientSide }}</script>{% endverbatim %}
{% endblock content %}
-22
View File
@@ -1,22 +0,0 @@
import os
import unittest
from django.test import SimpleTestCase
from django.urls import reverse
class ProductPagesTests(SimpleTestCase):
def test_lists_every_product(self):
response = self.client.get(reverse("shop:products"))
self.assertContains(response, "Espresso")
self.assertContains(response, "Cornetto")
self.assertContains(response, "€1.60")
def test_unknown_product_is_not_found(self):
response = self.client.get(reverse("shop:product", args=["tea"]))
self.assertEqual(response.status_code, 404)
@unittest.skipUnless(os.environ.get("OPENIDEA_FAIL"), "opt-in failing test")
def test_intentionally_failing(self):
# FIXME: the opt-in failing test, for the test runner's failure views.
self.assertContains(self.client.get(reverse("shop:products")), "Tea")
-10
View File
@@ -1,10 +0,0 @@
from django.urls import path
from . import views
app_name = "shop"
urlpatterns = [
path("", views.product_list, name="products"),
path("products/<slug:slug>/", views.product_detail, name="product"),
]
-35
View File
@@ -1,35 +0,0 @@
"""Views of the shop: a product list and a product page."""
import json # Intentional warning: unused import.
from dataclasses import dataclass
from decimal import Decimal
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import render
@dataclass(frozen=True)
class Product:
slug: str
name: str
price: Decimal
tags: tuple[str, ...] = ()
PRODUCTS = [
Product("espresso", "Espresso", Decimal("1.20"), ("coffee", "hot")),
Product("cappuccino", "Cappuccino", Decimal("1.60"), ("coffee", "milk")),
Product("cornetto", "Cornetto", Decimal("1.30")),
]
def product_list(request: HttpRequest) -> HttpResponse:
# TODO: paginate once the menu grows.
return render(request, "shop/product_list.html", {"products": PRODUCTS})
def product_detail(request: HttpRequest, slug: str) -> HttpResponse:
product = next((p for p in PRODUCTS if p.slug == slug), None) # Set a breakpoint here
if product is None:
raise Http404(slug)
return render(request, "shop/product_list.html", {"products": [product], "single": True})
+1 -1
View File
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.13.0
Bundle-Version: 1.12.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
+1 -1
View File
@@ -88,7 +88,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.13.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.12.0.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
Binary file not shown.
@@ -1,21 +0,0 @@
package ai.openidea;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.ls.core.internal.JobHelpers;
import org.eclipse.jdt.ls.core.internal.handlers.BaseDocumentLifeCycleHandler;
/** What a command that reads an editor's position needs before it resolves the position. */
public final class Documents {
private Documents() {}
/**
* Waits for the queued document changes to be reconciled, as JDT LS's own position requests
* do: `workspace/executeCommand` does not, so a position sent right after an edit would
* be resolved against the element structure from before it.
*/
public static void awaitChanges(IProgressMonitor monitor) {
JobHelpers.waitForJobs(BaseDocumentLifeCycleHandler.DOCUMENT_LIFE_CYCLE_JOBS, monitor);
if (monitor.isCanceled()) throw new OperationCanceledException();
}
}
@@ -1,6 +1,5 @@
package ai.openidea.configuration;
import ai.openidea.Documents;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -97,7 +96,6 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
if (command.equals("openidea.java.configuration.warmup")) return ConfigurationWarmup.request(args);
Documents.awaitChanges(monitor);
String uri = (String) args.get(0);
String key = args.get(1) instanceof String s ? s : null;
int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue();
@@ -1,6 +1,5 @@
package ai.openidea.hierarchy;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -45,7 +44,6 @@ public final class HierarchyCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
@@ -1,6 +1,5 @@
package ai.openidea.implementations;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -28,7 +27,6 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
List<Location> locations = new ArrayList<>();
boolean complete = find(arguments, locations, monitor);
return Map.of("locations", locations, "complete", complete);
@@ -1,6 +1,5 @@
package ai.openidea.usages;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -30,7 +29,6 @@ import org.eclipse.lsp4j.*;
public final class UsagesCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
-23
View File
@@ -1,23 +0,0 @@
id = "django-html"
name = "Django Template"
extensions = ["djhtml"]
block-comment = ["{#", "#}"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "Django Template"
tree-sitter = "html"
# Django projects: the HTML and text files in a `templates` folder.
[[framework-files]]
folder = "templates"
extensions = ["html", "txt"]
root-markers = ["manage.py"]
# The markup between the template tags: completion and hover of tags and
# attributes. The template tags are text to it. No formatter: HTML formatters
# reflow template tags.
[[language-servers]]
name = "vscode-html-language-server"
command = "vscode-html-language-server"
args = ["--stdio"]
initialization-options = { embeddedLanguages = { css = true, javascript = true } }
install-hint = "npm i -g vscode-langservers-extracted"
+1 -1
View File
@@ -17,7 +17,7 @@ func main() {
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
initialization-options = { semanticTokens = true }
root-markers = ["go.work", "go.mod"]
install-hint = "go install golang.org/x/tools/gopls@latest"
+1 -1
View File
@@ -8,6 +8,6 @@ ui-mode = "GoMod"
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
initialization-options = { semanticTokens = true }
root-markers = ["go.work", "go.mod"]
install-hint = "go install golang.org/x/tools/gopls@latest"
+1 -1
View File
@@ -8,6 +8,6 @@ ui-mode = "GoMod"
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
initialization-options = { semanticTokens = true }
root-markers = ["go.work"]
install-hint = "go install golang.org/x/tools/gopls@latest"
-9
View File
@@ -29,15 +29,6 @@ max-java = 21
root-markers = ["settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle", "pom.xml"]
install-hint = "brew install kotlin-language-server (https://github.com/fwcd/kotlin-language-server)"
[[language-servers]]
name = "kmp-lsp"
# Alternative: tree-sitter based, starts at once and needs no JVM or Gradle import, but resolves
# symbols syntactically. Used only when the servers above are not installed.
fallback = true
command = "kmp-lsp"
root-markers = ["settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle", "pom.xml"]
install-hint = "Install kmp-lsp in Settings › Languages & Frameworks › Language Servers (https://github.com/Hessesian/kmp-lsp)"
[debugger]
adapter = "kotlin-debug-adapter"
command = "kotlin-debug-adapter"
-1
View File
@@ -22,7 +22,6 @@
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
"gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts",
"gen:django-grammar": "lezer-generator src/editor/core/cm/django/django.grammar --typeScript -o src/editor/core/cm/django/parser.ts",
"gen:astro-grammar": "lezer-generator src/editor/core/cm/astro/astro.grammar --typeScript -o src/editor/core/cm/astro/parser.ts",
"gen:theme-docs": "node scripts/theme/gen-docs.ts",
"theme:import-intellij": "node scripts/theme/import-intellij-theme.ts",
+11 -14
View File
@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
mermaid>katex: ^0.18.2
importers:
.:
@@ -1481,14 +1478,14 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-cgnQ88TRrWcC7pVq46RzJVRMktu3W2RcbnoidnfBsl1bmDCBwJS/udcIZpKoCrbAYYaCqsz1yZCVIgbIaU3ddQ==}
engines: {node: '>=22.12.0'}
[email protected]:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
[email protected]:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
[email protected]:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
@@ -1789,8 +1786,8 @@ packages:
canvas:
optional: true
[email protected]8.10:
resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
[email protected]6.47:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
[email protected]:
@@ -3456,10 +3453,10 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
layout-base: 1.0.2
@@ -3782,9 +3779,9 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
[email protected]8.10:
[email protected]6.47:
dependencies:
commander: 15.1.0
commander: 8.3.0
[email protected]: {}
@@ -3898,7 +3895,7 @@ snapshots:
dompurify: 3.4.17
elkjs: 0.9.3
es-toolkit: 1.53.0
katex: 0.18.10
katex: 0.16.47
khroma: 2.1.0
marked: 16.4.2
roughjs: 4.6.6
-3
View File
@@ -1,5 +1,2 @@
allowBuilds:
simple-git-hooks: true
overrides:
# GHSA-238p-pmpm-9mq7 (fixed in 0.18.2); mermaid 12.1 still asks for katex ^0.16.
mermaid>katex: ^0.18.2
-1
View File
@@ -108,7 +108,6 @@ windows = { version = "0.62", features = [
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
-13
View File
@@ -128,19 +128,6 @@ pub async fn remote_mkdir(state: State<'_, AppState>, connection: FsConnection,
ide_host::ops::deploy::remote_mkdir(&state.host, connection, path).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_download_into(
window: Window,
state: State<'_, AppState>,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
ide_host::ops::deploy::remote_download_into(window.label(), &state.host, connection, paths, target, request).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_rename(
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff. Show more