mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 10:41:28 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbcd1818a7 |
No files matched your search
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"), &[]));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>)>;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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()
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms),
|
||||
"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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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 = ¶ms["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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 | — | — | — |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 文件。
|
||||
|
||||
@@ -105,7 +105,7 @@ OpenIDEA 自带 SSH 客户端,自行读取 `~/.ssh/config`,支持 `Host`、`
|
||||
|
||||
- **运行目标**(**设置 › 构建、执行、部署 › 运行目标**)可以在 SSH 主机上执行运行配置,见[运行配置](/zh/guide/run)。
|
||||
- **数据源**可以通过 SSH 隧道连接,见[数据库](/zh/guide/database)。
|
||||
- **工具 › 部署**通过 SFTP、FTP 或 FTPS 与服务器上传、下载和同步项目文件。
|
||||
- **工具 › 部署**通过 SFTP 与服务器上传、下载和同步项目文件。
|
||||
|
||||
## 限制
|
||||
|
||||
|
||||
@@ -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` | 是 | — | — | — |
|
||||
|
||||
@@ -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 语言服务器)里选择:**自动** 使用第一个可用的,**无** 关闭代码智能。这个选择可以全局生效,也可以只对当前项目生效。
|
||||
|
||||
|
||||
@@ -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 配置的项目中运行。
|
||||
|
||||
@@ -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 服务器 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [path("", include("shop.urls"))]
|
||||
@@ -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()
|
||||
@@ -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 +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 %}
|
||||
@@ -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")
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+11
-14
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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
Reference in new issue
Block a user