дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 14:15:39 +02:00
Порівняти коміти
16
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
54e3b63547 | ||
|
|
0af3bd00e7 | ||
|
|
951e81e065 | ||
|
|
023c0a6ee6 | ||
|
|
c5481e8fe4 | ||
|
|
dfacb45575 | ||
|
|
3363bb8a46 | ||
|
|
39f34ec22f | ||
|
|
34ae8acbf8 | ||
|
|
6bafcb2b5c | ||
|
|
a6ed65e6d1 | ||
|
|
009e61d27b | ||
|
|
a89c0dd229 | ||
|
|
ea644dcdb0 | ||
|
|
519c8ad8fc | ||
|
|
27fdbe71e5 |
No files matched your search
@@ -17,11 +17,30 @@ const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else
|
||||
|
||||
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
|
||||
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Discovered, String> {
|
||||
let prefer_stored = crate::provider::prefer_stored_copilot_auth();
|
||||
match run_once(shared, spec, prefer_stored).await {
|
||||
Err(err)
|
||||
if !prefer_stored
|
||||
&& crate::provider::is_copilot_provider(&spec.id)
|
||||
&& crate::provider::has_unconfigured_copilot_ambient_token(&spec.env)
|
||||
&& super::auth_required(0, &err) =>
|
||||
{
|
||||
let retry = run_once(shared, spec, true).await;
|
||||
if retry.is_ok() {
|
||||
crate::provider::mark_prefer_stored_copilot_auth();
|
||||
}
|
||||
retry
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool) -> Result<Discovered, String> {
|
||||
let root = shared.paths.project_root.clone();
|
||||
let launch = spec.clone();
|
||||
let launch = tokio::task::spawn_blocking({
|
||||
let root = root.clone();
|
||||
move || launch.resolve_for_launch(&root)
|
||||
move || launch.resolve_for_launch_with_copilot_env(&root, strip_ambient)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -120,10 +120,18 @@ impl ProviderSpec {
|
||||
}
|
||||
|
||||
/// Resolve once before connecting, so authentication guidance names that same install.
|
||||
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
|
||||
pub(crate) fn resolve_for_launch(self, cwd: &Path) -> Self {
|
||||
let strip = prefer_stored_copilot_auth();
|
||||
self.resolve_for_launch_with_copilot_env(cwd, strip)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_for_launch_with_copilot_env(mut self, cwd: &Path, strip_ambient_tokens: bool) -> Self {
|
||||
if self.driver != Driver::Acp {
|
||||
self.command = crate::native::newest_install(&self.command);
|
||||
}
|
||||
if is_copilot_provider(&self.id) {
|
||||
self.env = sanitized_copilot_env(&self.env, strip_ambient_tokens);
|
||||
}
|
||||
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
|
||||
self.command =
|
||||
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
|
||||
@@ -133,6 +141,69 @@ impl ProviderSpec {
|
||||
}
|
||||
}
|
||||
|
||||
const AMBIENT_COPILOT_AUTH_VARS: [&str; 3] = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
||||
static PREFER_STORED_COPILOT_AUTH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn is_copilot_provider(id: &str) -> bool {
|
||||
id == "copilot" || id.starts_with("copilot-")
|
||||
}
|
||||
|
||||
pub(crate) fn prefer_stored_copilot_auth() -> bool {
|
||||
PREFER_STORED_COPILOT_AUTH.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_prefer_stored_copilot_auth() {
|
||||
PREFER_STORED_COPILOT_AUTH.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) fn has_unconfigured_copilot_ambient_token(spec_env: &[(String, String)]) -> bool {
|
||||
AMBIENT_COPILOT_AUTH_VARS.iter().any(|&key| {
|
||||
!spec_env.iter().any(|(k, v)| k == key && !v.is_empty())
|
||||
&& ambient_copilot_env_value(key).is_some_and(|v| !v.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn ambient_copilot_env_value(key: &str) -> Option<String> {
|
||||
ide_core::process::shell_env()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v.to_string_lossy().into_owned())
|
||||
.or_else(|| ide_core::process::host_var_os(key).map(|v| v.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Builds child environment overrides for `copilot`:
|
||||
/// - Always clears ambient classic PATs (`ghp_…`), which Copilot CLI unconditionally rejects
|
||||
/// (`"Classic PATs are not supported"`) instead of falling back to Keychain / `gh` login.
|
||||
/// - When `strip_ambient_tokens` is true, clears unconfigured ambient `GITHUB_TOKEN` / `GH_TOKEN` /
|
||||
/// `COPILOT_GITHUB_TOKEN` so Copilot uses its stored login (`~/.copilot` or `gh auth` keyring),
|
||||
/// while preserving any token explicitly configured in OpenIDEA's agent `spec_env`.
|
||||
pub(crate) fn sanitized_copilot_env(
|
||||
spec_env: &[(String, String)],
|
||||
strip_ambient_tokens: bool,
|
||||
) -> Vec<(String, String)> {
|
||||
sanitize_copilot_env_with(spec_env, strip_ambient_tokens, ambient_copilot_env_value)
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_copilot_env_with(
|
||||
spec_env: &[(String, String)],
|
||||
strip_ambient_tokens: bool,
|
||||
lookup: impl Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut out = Vec::with_capacity(spec_env.len() + AMBIENT_COPILOT_AUTH_VARS.len());
|
||||
for key in AMBIENT_COPILOT_AUTH_VARS {
|
||||
let explicit = spec_env.iter().any(|(k, v)| k == key && !v.is_empty());
|
||||
if explicit {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = lookup(key).filter(|v| !v.is_empty())
|
||||
&& (strip_ambient_tokens || val.trim().starts_with("ghp_"))
|
||||
{
|
||||
out.push((key.to_owned(), String::new()));
|
||||
}
|
||||
}
|
||||
out.extend(spec_env.iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
|
||||
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
|
||||
let prefix = format!("Run `{}", builtin.command);
|
||||
@@ -1101,4 +1172,23 @@ mod tests {
|
||||
import_acp(dir.path(), None, "not json").unwrap_err();
|
||||
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_classic_pats_and_ambient_tokens_while_preserving_explicit_agent_env() {
|
||||
let lookup = |k: &str| match k {
|
||||
"GITHUB_TOKEN" => Some("ghp_classic_pat_123".into()),
|
||||
"GH_TOKEN" => Some("gho_oauth_token_456".into()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(sanitize_copilot_env_with(&[], false, lookup), vec![("GITHUB_TOKEN".into(), String::new())]);
|
||||
assert_eq!(
|
||||
sanitize_copilot_env_with(&[], true, lookup),
|
||||
vec![("GH_TOKEN".into(), String::new()), ("GITHUB_TOKEN".into(), String::new())]
|
||||
);
|
||||
let explicit = vec![("GH_TOKEN".into(), "github_pat_custom".into())];
|
||||
assert_eq!(
|
||||
sanitize_copilot_env_with(&explicit, true, lookup),
|
||||
vec![("GITHUB_TOKEN".into(), String::new()), ("GH_TOKEN".into(), "github_pat_custom".into())]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -883,6 +883,7 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: ide_core::input::InputWriter,
|
||||
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pid: Option<u32>,
|
||||
/// Set once the program has exited and been reaped.
|
||||
exited: Arc<AtomicBool>,
|
||||
@@ -895,6 +896,7 @@ impl ProgramTerminal {
|
||||
}
|
||||
|
||||
/// The program's process id while it runs.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) fn process_id(&self) -> Option<u32> {
|
||||
if self.exited.load(Ordering::Acquire) { None } else { self.pid }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,28 @@ pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
|
||||
/// through [`save_as`], which turns it into a regular file document. `name`
|
||||
/// re-references an already minted buffer instead of creating one.
|
||||
pub async fn open_untitled(host: &Host, window: &str, name: Option<String>) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.open_untitled(name.as_deref())).await
|
||||
}
|
||||
|
||||
/// Save As for an untitled buffer ([`Documents::save_as`]); LightEdit windows
|
||||
/// start watching the saved file's folder.
|
||||
pub async fn save_as(host: &Host, window: &str, doc: DocId, path: PathBuf) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let info = project.documents.save_as(doc, &path)?;
|
||||
if let Some(dir) = path.parent().filter(|_| project.light) {
|
||||
project.watch_light_dir(dir);
|
||||
}
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
|
||||
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
host.project(window)?.documents.edit(doc, base_version, changes)
|
||||
|
||||
@@ -50,7 +50,10 @@ 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)? {
|
||||
if reload
|
||||
&& !project.documents.is_untitled(doc)?
|
||||
&& 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 {
|
||||
|
||||
@@ -77,9 +77,22 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest side of a plugin diff, in UTF-16 code units (earlier hosts allowed 1 MiB).
|
||||
pub(super) const MAX_DIFF_SIDE: usize = 8 << 20;
|
||||
|
||||
fn diff_side(value: &str, side: &str) -> Result<(), HostError> {
|
||||
if value.contains('\0') {
|
||||
return Err(HostError::Invalid(format!("the {side} side of the diff contains NUL")));
|
||||
}
|
||||
if value.encode_utf16().count() > MAX_DIFF_SIDE {
|
||||
return Err(HostError::Invalid(format!("diff too large: the {side} side is over {} MiB", MAX_DIFF_SIDE >> 20)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
diff_side(&diff.left, "left")?;
|
||||
diff_side(&diff.right, "right")?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
@@ -571,3 +584,25 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn diff(left: String, right: String) -> ide_plugin::ContentDiff {
|
||||
ide_plugin::ContentDiff { left, right, left_label: None, right_label: None, title: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_sides_up_to_the_limit_open_and_larger_ones_say_why() {
|
||||
assert!(validate_diff(&diff("x".repeat(5 << 20), "y".repeat(MAX_DIFF_SIDE))).is_ok());
|
||||
let err = validate_diff(&diff(String::new(), "y".repeat(MAX_DIFF_SIDE + 1))).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, HostError::Invalid(m) if m == "diff too large: the right side is over 8 MiB"),
|
||||
"{err:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(validate_diff(&diff("a\0".into(), String::new())), Err(HostError::Invalid(m)) if m.contains("NUL"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,9 @@ impl Host {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Untitled buffers belong to the window, not a project: they follow it
|
||||
// into the project's fresh document registry.
|
||||
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
@@ -621,6 +624,9 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
@@ -703,12 +709,29 @@ impl Host {
|
||||
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
|
||||
}
|
||||
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
|
||||
let watch = dir.clone();
|
||||
self.open_light_in(window, dir, Some(&watch))
|
||||
}
|
||||
|
||||
/// LightEdit without a file (a welcome window's File › New File): rooted
|
||||
/// at the user's home folder, watching nothing until a buffer is saved
|
||||
/// into a real folder ([`Project::watch_light_dir`]).
|
||||
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
let dir = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
|
||||
self.open_light_in(window, dir, None)
|
||||
}
|
||||
|
||||
fn open_light_in(&self, window: &str, dir: PathBuf, watch_dir: Option<&Path>) -> IdeResult<Arc<Project>> {
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Untitled buffers belong to the window: they follow it out of a project.
|
||||
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.light
|
||||
{
|
||||
current.watch_light_dir(&dir);
|
||||
if let Some(dir) = watch_dir {
|
||||
current.watch_light_dir(dir);
|
||||
}
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
@@ -727,7 +750,16 @@ impl Host {
|
||||
let syntax = Arc::new(SyntaxCache::new(self.syntax()));
|
||||
documents.add_observer(Arc::new(SyntaxObserver(syntax.clone())));
|
||||
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
|
||||
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
|
||||
let watcher = match watch_dir {
|
||||
Some(dir) => light_watcher(dir, &sink, &documents, &history)?,
|
||||
// A quiet private folder stands in until the first save brings a
|
||||
// real folder in.
|
||||
None => {
|
||||
let idle = self.local_data_dir.join("light-edit");
|
||||
std::fs::create_dir_all(&idle)?;
|
||||
light_watcher(&idle, &sink, &documents, &history)?
|
||||
}
|
||||
};
|
||||
let project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
@@ -773,8 +805,11 @@ impl Host {
|
||||
light: true,
|
||||
root: dir,
|
||||
});
|
||||
tracing::info!("window {window} edits {} without a project", file.display());
|
||||
tracing::info!("window {window} edits {} without a project", project.root.display());
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,13 @@ impl Dispatcher {
|
||||
})
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open_untitled", |c| async move {
|
||||
ops::doc::open_untitled(&c.host, &c.window, c.arg::<Option<String>>("name")?).await
|
||||
})
|
||||
.add(
|
||||
"doc_save_as",
|
||||
|c| async move { ops::doc::save_as(&c.host, &c.window, c.arg("doc")?, c.arg("path")?).await },
|
||||
)
|
||||
.add("doc_close", |c| async move {
|
||||
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
|
||||
})
|
||||
|
||||
@@ -401,6 +401,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "web_unresolved_references"
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "light_edit_open_untitled"
|
||||
| "workspace_create"
|
||||
| "workspace_discover_projects"
|
||||
| "workspace_update"
|
||||
|
||||
@@ -179,3 +179,73 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
|
||||
assert!(!bin.join("python.probed").exists());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn untitled_buffers_reach_the_disk_through_save_as() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let dir = tmp_root.join("notes");
|
||||
let host = host(&tmp_root.join("app"));
|
||||
|
||||
// A window with no file (a welcome screen's File › New File): rooted at the
|
||||
// home folder, no watcher yet, doc commands work.
|
||||
let empty = host.open_light_empty("main").unwrap();
|
||||
assert!(empty.light);
|
||||
let untitled = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
assert_eq!(untitled.path, std::path::PathBuf::from("untitled:Untitled-1"));
|
||||
assert_eq!(untitled.language.as_str(), "plaintext");
|
||||
let version = ops::doc::edit(&host, "main", untitled.id, untitled.version, untitled_edit("# notes\n")).unwrap();
|
||||
assert!(ops::doc::save(&host, "main", untitled.id).await.is_ok(), "saving nowhere is a no-op");
|
||||
assert!(host.project("main").unwrap().documents.is_dirty(untitled.id).unwrap());
|
||||
|
||||
// Save As: the buffer lands on the disk and turns into a file document.
|
||||
let target = dir.join("deep/notes.md");
|
||||
let saved = ops::doc::save_as(&host, "main", untitled.id, target.clone()).await.unwrap();
|
||||
assert_eq!(saved.path, target);
|
||||
assert_eq!(saved.language.as_str(), "markdown");
|
||||
assert!(!saved.dirty);
|
||||
// The platform's default line separator applies to new buffers.
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "# notes\n");
|
||||
ops::doc::edit(&host, "main", untitled.id, version, untitled_edit("x\n")).unwrap();
|
||||
ops::doc::save(&host, "main", untitled.id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "x\n# notes\n");
|
||||
|
||||
// The saved file's folder is followed for external changes now.
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
fn untitled_edit(insert: &str) -> ChangeSet {
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: insert.into() }] }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn untitled_buffers_follow_the_window_into_a_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = host(&tmp_root.join("app"));
|
||||
|
||||
// A LightEdit window with an edited untitled buffer (and a clean one).
|
||||
host.open_light_empty("main").unwrap();
|
||||
let draft = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
ops::doc::edit(&host, "main", draft.id, draft.version, untitled_edit("{\"draft\": true}")).unwrap();
|
||||
let clean = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
|
||||
// The window switches to a project: the buffers follow, contents and dirty flags kept.
|
||||
let project_root = tmp_root.join("proj");
|
||||
std::fs::create_dir_all(&project_root).unwrap();
|
||||
let project = host.open_project("main", &project_root).unwrap();
|
||||
assert!(!project.light);
|
||||
|
||||
let carried =
|
||||
ops::doc::open_untitled(&host, "main", Some(draft.path.to_string_lossy().into_owned())).await.unwrap();
|
||||
assert_eq!(carried.path, draft.path, "same key, fresh registry: a new doc id carries the content");
|
||||
let snap = ops::doc::snapshot(&host, "main", carried.id).await.unwrap();
|
||||
assert_eq!(snap.text, "{\"draft\": true}");
|
||||
assert!(host.project("main").unwrap().documents.is_dirty(carried.id).unwrap());
|
||||
assert_eq!(snap.language.as_str(), "plaintext");
|
||||
|
||||
let clean_carried =
|
||||
ops::doc::open_untitled(&host, "main", Some(clean.path.to_string_lossy().into_owned())).await.unwrap();
|
||||
assert!(!host.project("main").unwrap().documents.is_dirty(clean_carried.id).unwrap());
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -80,18 +80,47 @@ pub fn trim_line_end(line: &[u8]) -> &[u8] {
|
||||
&line[..end]
|
||||
}
|
||||
|
||||
/// The longest line kept in full; the rest of a longer one is dropped (a server that writes a
|
||||
/// progress bar with `\r` only, or never a newline, must not grow memory without bound).
|
||||
pub const MAX_LINE: usize = 8 * 1024;
|
||||
|
||||
/// Reads `stream` to its end, calling `on_line` with each line decoded by [`decode`].
|
||||
/// Unlike `AsyncBufReadExt::lines`, a line that is not UTF-8 does not end the stream.
|
||||
/// A line longer than [`MAX_LINE`] bytes is cut there and ends with `…`.
|
||||
pub async fn read_lines<R: AsyncRead + Unpin>(stream: R, mut on_line: impl FnMut(String)) {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = Vec::new();
|
||||
let mut cut = false;
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_until(b'\n', &mut line).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => on_line(decode(trim_line_end(&line))),
|
||||
let (used, complete) = match reader.fill_buf().await {
|
||||
Ok([]) | Err(_) => break,
|
||||
Ok(buf) => {
|
||||
let end = buf.iter().position(|&b| b == b'\n');
|
||||
let piece = &buf[..end.unwrap_or(buf.len())];
|
||||
let room = MAX_LINE.saturating_sub(line.len());
|
||||
cut |= piece.len() > room;
|
||||
line.extend_from_slice(&piece[..piece.len().min(room)]);
|
||||
(end.map_or(buf.len(), |i| i + 1), end.is_some())
|
||||
}
|
||||
};
|
||||
reader.consume(used);
|
||||
if complete {
|
||||
emit(&mut line, &mut cut, &mut on_line);
|
||||
}
|
||||
}
|
||||
if !line.is_empty() || cut {
|
||||
emit(&mut line, &mut cut, &mut on_line);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(line: &mut Vec<u8>, cut: &mut bool, on_line: &mut impl FnMut(String)) {
|
||||
let mut text = decode(trim_line_end(line));
|
||||
if *cut {
|
||||
text.push('…');
|
||||
}
|
||||
line.clear();
|
||||
*cut = false;
|
||||
on_line(text);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -133,4 +162,19 @@ mod tests {
|
||||
assert!(lines[1].ends_with(" broken"));
|
||||
assert_eq!(lines[2], "last");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_very_long_line_is_cut_and_the_next_one_is_intact() {
|
||||
let mut input = vec![b'x'; MAX_LINE * 10 + 3];
|
||||
input.extend_from_slice(b"\nnext\r\n");
|
||||
// No newline at all after this one: still bounded.
|
||||
input.extend_from_slice(&vec![b'y'; MAX_LINE * 3]);
|
||||
let mut lines = Vec::new();
|
||||
read_lines(&input[..], |l| lines.push(l)).await;
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0].chars().count(), MAX_LINE + 1);
|
||||
assert!(lines[0].ends_with('…') && lines[0].starts_with("xxx"));
|
||||
assert_eq!(lines[1], "next");
|
||||
assert_eq!(lines[2].chars().count(), MAX_LINE + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
; Shell scripts: drawn over the stream mode, which knows only fixed word lists.
|
||||
; The last matching pattern wins.
|
||||
|
||||
[
|
||||
(string)
|
||||
(raw_string)
|
||||
(ansi_c_string)
|
||||
(translated_string)
|
||||
(heredoc_body)
|
||||
(heredoc_start)
|
||||
] @string
|
||||
|
||||
[
|
||||
(command_substitution)
|
||||
(process_substitution)
|
||||
(expansion)
|
||||
(simple_expansion)
|
||||
] @embedded
|
||||
|
||||
(comment) @comment
|
||||
|
||||
[
|
||||
"case"
|
||||
"do"
|
||||
"done"
|
||||
"elif"
|
||||
"else"
|
||||
"esac"
|
||||
"fi"
|
||||
"for"
|
||||
"function"
|
||||
"if"
|
||||
"in"
|
||||
"select"
|
||||
"then"
|
||||
"until"
|
||||
"while"
|
||||
"declare"
|
||||
"export"
|
||||
"local"
|
||||
"readonly"
|
||||
"typeset"
|
||||
"unset"
|
||||
"unsetenv"
|
||||
] @keyword
|
||||
|
||||
(command_name) @function.command
|
||||
(function_definition name: (word) @function.definition)
|
||||
|
||||
(variable_name) @variable
|
||||
(special_variable_name) @variable
|
||||
|
||||
[
|
||||
(number)
|
||||
(file_descriptor)
|
||||
] @number
|
||||
@@ -314,7 +314,7 @@ pub(crate) static SPECS: &[Spec] = &[
|
||||
name: "bash",
|
||||
language: tree_sitter_bash::LANGUAGE,
|
||||
outline: outline!("bash"),
|
||||
highlights: None,
|
||||
highlights: Some(Highlights { query: highlights!("bash"), first_wins: false }),
|
||||
fold_nodes: &["do_group", "if_statement", "case_statement", "heredoc_body"],
|
||||
fold_bodies: NONE,
|
||||
imports: NONE,
|
||||
|
||||
@@ -43,11 +43,13 @@ pub enum SyntaxHighlightKind {
|
||||
Operator,
|
||||
Punctuation,
|
||||
Parameter,
|
||||
/// A shell command name (`pgrep`, a script's own function).
|
||||
Command,
|
||||
}
|
||||
|
||||
impl SyntaxHighlightKind {
|
||||
/// Every kind, in `as u32` order: the legend for [`SyntaxHighlights::data`].
|
||||
pub const ALL: [Self; 25] = [
|
||||
pub const ALL: [Self; 26] = [
|
||||
Self::Keyword,
|
||||
Self::String,
|
||||
Self::StringSpecial,
|
||||
@@ -73,6 +75,7 @@ impl SyntaxHighlightKind {
|
||||
Self::Operator,
|
||||
Self::Punctuation,
|
||||
Self::Parameter,
|
||||
Self::Command,
|
||||
];
|
||||
|
||||
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
|
||||
@@ -153,6 +156,7 @@ const CAPTURES: &[(&str, Paint)] = {
|
||||
("function.builtin", Kind(K::Builtin)),
|
||||
("function.macro", Kind(K::Macro)),
|
||||
("function.definition", Kind(K::Function)),
|
||||
("function.command", Kind(K::Command)),
|
||||
("function", Kind(K::FunctionCall)),
|
||||
("method", Kind(K::FunctionCall)),
|
||||
("constructor", Kind(K::Type)),
|
||||
@@ -408,6 +412,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_scripts_highlight_declarations_commands_and_function_names() {
|
||||
use SyntaxHighlightKind as K;
|
||||
let src = "this::check_process() {\n local process\n process=\"${1:?}\"\n if [ -x \"$(command -v pgrep)\" ]; then\n if pgrep -f -q \"${process}\"; then\n functions::exit 1 \"'${process}' is running\"\n fi\n fi\n}\n# done\n";
|
||||
let got = spans("shellscript", src);
|
||||
for (text, expected) in [
|
||||
("this::check_process", K::Function),
|
||||
("local", K::Keyword),
|
||||
("if", K::Keyword),
|
||||
("command", K::Command),
|
||||
("pgrep", K::Command),
|
||||
("functions::exit", K::Command),
|
||||
("process", K::Variable),
|
||||
("# done", K::Comment),
|
||||
] {
|
||||
assert!(
|
||||
got.iter().any(|(word, kind)| word == text && *kind == expected),
|
||||
"missing {text}: {expected:?} in {got:?}"
|
||||
);
|
||||
}
|
||||
// An expansion inside a string is not string-coloured.
|
||||
assert!(!got.iter().any(|(word, kind)| word.contains("${process}") && *kind == K::String), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_kotlin_parameters_keep_variable_highlighting() {
|
||||
for (lang, src) in [
|
||||
|
||||
@@ -41,20 +41,44 @@ pub struct ClientHandler {
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// The diagnostics in `items` (a JSON array) that deserialize. A server's malformed one is
|
||||
/// skipped with a warning, so the rest of the file's problems still show.
|
||||
pub(crate) fn lenient_diagnostics(server: &str, items: Value) -> Vec<lsp::Diagnostic> {
|
||||
let Value::Array(items) = items else { return Vec::new() };
|
||||
let mut skipped = 0usize;
|
||||
let parsed: Vec<lsp::Diagnostic> = items
|
||||
.into_iter()
|
||||
.filter_map(|mut item| {
|
||||
crate::virtual_doc::normalize_uris(&mut item);
|
||||
match serde_json::from_value(item) {
|
||||
Ok(diagnostic) => Some(diagnostic),
|
||||
Err(_) => {
|
||||
skipped += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if skipped > 0 {
|
||||
tracing::warn!("{server}: skipped {skipped} malformed diagnostic(s)");
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
impl ClientHandler {
|
||||
pub fn new(mgr: Weak<LspManager>, server: Arc<Server>, generation: u64) -> Self {
|
||||
Self { mgr, server, generation }
|
||||
}
|
||||
|
||||
fn encoding(&self) -> Encoding {
|
||||
self.server.running_gen(self.generation).map(|r| r.caps.read().encoding).unwrap_or_default()
|
||||
self.server.initialized_gen(self.generation).map(|r| r.caps.read().encoding).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Cancelling task `task` asks this server instance to stop the work of progress `token`.
|
||||
fn cancel_handle(&self, task: TaskId, token: &Value) -> ide_core::task::CancelHandle {
|
||||
let (server, generation, token) = (self.server.clone(), self.generation, token.clone());
|
||||
ide_core::task::on_cancel(task, move || {
|
||||
if let Some(run) = server.running_gen(generation)
|
||||
if let Some(run) = server.initialized_gen(generation)
|
||||
&& let Err(e) = run.peer.notify("window/workDoneProgress/cancel", json!({ "token": token }))
|
||||
{
|
||||
tracing::debug!("{}: cannot cancel progress: {e}", server.name);
|
||||
@@ -176,7 +200,7 @@ impl ClientHandler {
|
||||
loop {
|
||||
tokio::time::sleep(PROGRESS_SWEEP).await;
|
||||
let Some(mgr) = weak.upgrade() else { return };
|
||||
if server.running_gen(generation).is_none() || !server.has_progress(&token) {
|
||||
if !server.is_live(generation) || !server.has_progress(&token) {
|
||||
return; // the instance is gone, or the task ended normally
|
||||
}
|
||||
if server.progress_silent(&token, PROGRESS_STALE) {
|
||||
@@ -251,18 +275,24 @@ impl ClientHandler {
|
||||
|
||||
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
|
||||
// Problems come only from servers used for `diagnostics`.
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
if self.server.initialized_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
return;
|
||||
}
|
||||
let mut params = params;
|
||||
crate::virtual_doc::normalize_uris(&mut params);
|
||||
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
// One malformed diagnostic must not take the others of the file with it.
|
||||
let raw = params.get_mut("diagnostics").map(Value::take);
|
||||
if let Some(fields) = params.as_object_mut() {
|
||||
fields.insert("diagnostics".into(), json!([]));
|
||||
}
|
||||
let mut params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: bad publishDiagnostics: {e}", self.server.name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
params.diagnostics = lenient_diagnostics(&self.server.name, raw.unwrap_or(Value::Null));
|
||||
let Some(path) = uri_to_path(params.uri.as_str())
|
||||
.or_else(|| crate::notebook::is_cell_uri(params.uri.as_str()).then(|| PathBuf::from(params.uri.as_str())))
|
||||
else {
|
||||
@@ -480,7 +510,7 @@ impl Handler for ClientHandler {
|
||||
crate::kotlin_lsp::record_import_state(&self.server, self.generation, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
if self.server.running_gen(self.generation).is_some() {
|
||||
if self.server.initialized_gen(self.generation).is_some() {
|
||||
self.server.set_java_ready();
|
||||
}
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
|
||||
@@ -103,7 +103,7 @@ impl Launcher for ProcessLauncher {
|
||||
// A plugin's server: the approved program, unchanged since.
|
||||
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
|
||||
None => ide_core::tool_path::find_command(&config.command, &search, root)
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b))
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b, root))
|
||||
.and_then(|b| reject_dead_shim(b, &search, root)),
|
||||
};
|
||||
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
|
||||
@@ -208,34 +208,16 @@ fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option<Path
|
||||
/// toolchain lacks the component (common for rust-analyzer). In that case use
|
||||
/// the newest installed toolchain that ships the binary; with none, the tool is
|
||||
/// missing (an install hint, not a server that crashes on every restart).
|
||||
fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
|
||||
/// The probe runs in the project's root, where a `rust-toolchain.toml` picks the toolchain.
|
||||
fn resolve_rustup_proxy(command: &str, binary: PathBuf, root: &Path) -> Option<PathBuf> {
|
||||
let is_proxy = binary.parent().is_some_and(|d| d.ends_with(".cargo/bin"));
|
||||
if !is_proxy || command.contains('/') {
|
||||
return Some(binary);
|
||||
}
|
||||
let works = ide_core::process::prepare(&mut std::process::Command::new(&binary))
|
||||
.arg("--version")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if works {
|
||||
if proxy_works(&binary, root) {
|
||||
return Some(binary);
|
||||
}
|
||||
let toolchains = std::env::var_os("RUSTUP_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".rustup")))
|
||||
.map(|h| h.join("toolchains"));
|
||||
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = toolchains
|
||||
.and_then(|dir| std::fs::read_dir(dir).ok())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path().join("bin").join(command))
|
||||
.filter(|p| p.is_file())
|
||||
.filter_map(|p| Some((p.metadata().ok()?.modified().ok()?, p)))
|
||||
.collect();
|
||||
let mut candidates = rustup_home().map(|h| toolchain_binaries(&h.join("toolchains"), command)).unwrap_or_default();
|
||||
// Prefer stable-looking toolchains, then the most recently installed.
|
||||
candidates.sort_by_key(|(mtime, p)| (!p.to_string_lossy().contains("nightly"), *mtime));
|
||||
match candidates.pop() {
|
||||
@@ -250,6 +232,57 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the proxy runs for the toolchain active in `root`. A probe that hangs or can't
|
||||
/// be waited for counts as working: the real start decides.
|
||||
fn proxy_works(binary: &Path, root: &Path) -> bool {
|
||||
let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(binary))
|
||||
.arg("--version")
|
||||
.current_dir(root)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return status.success(),
|
||||
Ok(None) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20))
|
||||
}
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where rustup keeps its toolchains: `RUSTUP_HOME`, else `.rustup` in the user's home
|
||||
/// (`HOME`, which a GUI app on Windows doesn't have, or `USERPROFILE`).
|
||||
fn rustup_home() -> Option<PathBuf> {
|
||||
std::env::var_os("RUSTUP_HOME").map(PathBuf::from).or_else(|| {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
|
||||
Some(PathBuf::from(home).join(".rustup"))
|
||||
})
|
||||
}
|
||||
|
||||
/// The `command` binary (with the platform's executable suffix) of every toolchain in
|
||||
/// `toolchains`, with its modification time.
|
||||
fn toolchain_binaries(toolchains: &Path, command: &str) -> Vec<(std::time::SystemTime, PathBuf)> {
|
||||
let file = format!("{command}{}", std::env::consts::EXE_SUFFIX);
|
||||
std::fs::read_dir(toolchains)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path().join("bin").join(&file))
|
||||
.filter(|p| p.is_file())
|
||||
.filter_map(|p| Some((p.metadata().ok()?.modified().ok()?, p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Workspace root for `file`: the outermost ancestor within `project_root`
|
||||
/// containing a root marker (so a Cargo/npm workspace gets one server, not one
|
||||
/// per member), else `project_root`. `None` when `file` is outside the project.
|
||||
@@ -363,7 +396,24 @@ mod proxy_tests {
|
||||
#[test]
|
||||
fn non_proxy_binaries_are_kept() {
|
||||
let p = PathBuf::from("/usr/bin/true");
|
||||
assert_eq!(resolve_rustup_proxy("true", p.clone()), Some(p));
|
||||
assert_eq!(resolve_rustup_proxy("true", p.clone(), Path::new(".")), Some(p));
|
||||
}
|
||||
|
||||
/// The binary is `rust-analyzer.exe` on Windows: a lookup of the bare name found nothing there.
|
||||
#[test]
|
||||
fn toolchain_binaries_carry_the_platform_executable_suffix() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = format!("rust-analyzer{}", std::env::consts::EXE_SUFFIX);
|
||||
for toolchain in ["stable-x86_64", "nightly-x86_64"] {
|
||||
std::fs::create_dir_all(tmp.path().join(toolchain).join("bin")).unwrap();
|
||||
}
|
||||
std::fs::write(tmp.path().join("stable-x86_64/bin").join(&file), "").unwrap();
|
||||
// Another tool, and a bare name without the suffix where one is required.
|
||||
std::fs::write(tmp.path().join("nightly-x86_64/bin").join("rustfmt"), "").unwrap();
|
||||
let found = toolchain_binaries(tmp.path(), "rust-analyzer");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].1, tmp.path().join("stable-x86_64/bin").join(&file));
|
||||
assert!(toolchain_binaries(&tmp.path().join("missing"), "rust-analyzer").is_empty());
|
||||
}
|
||||
|
||||
/// Exercises the real machine setup when a broken rustup proxy is present.
|
||||
@@ -372,7 +422,7 @@ mod proxy_tests {
|
||||
fn broken_rustup_proxy_falls_back_to_a_toolchain() {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").unwrap());
|
||||
let proxy = home.join(".cargo/bin/rust-analyzer");
|
||||
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone()).unwrap();
|
||||
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone(), Path::new(".")).unwrap();
|
||||
eprintln!("resolved: {}", resolved.display());
|
||||
assert_ne!(resolved, proxy);
|
||||
}
|
||||
|
||||
@@ -343,6 +343,10 @@ impl<T> Outcome<T> {
|
||||
/// How often a rename the server withdrew (`ContentModified`) is asked, and the pause between.
|
||||
const RENAME_ATTEMPTS: u32 = 5;
|
||||
const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
/// How often a semantic tokens, inlay hints or code lens request the server withdrew
|
||||
/// (`ContentModified`) is asked again, and the pause between.
|
||||
const STALE_RETRIES: u32 = 2;
|
||||
const STALE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
|
||||
/// Implementation requests of one line-marker pass in flight at once.
|
||||
const IMPLEMENTATION_CONCURRENCY: usize = 4;
|
||||
@@ -654,8 +658,9 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// The servers serving `path`: every regular one, and each `fallback` one
|
||||
/// only while all servers listed before it are missing. One that requires a
|
||||
/// root marker serves only files below one, and stands in for no other.
|
||||
/// only while all servers listed before it have given up (missing, not
|
||||
/// launchable, or crashed for good). One that requires a root marker serves
|
||||
/// only files below one, and stands in for no other.
|
||||
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
|
||||
if let Some(servers) = self.arduino_servers(language, path) {
|
||||
return servers;
|
||||
@@ -672,7 +677,7 @@ impl LspManager {
|
||||
continue;
|
||||
}
|
||||
let server = self.server_for(sc, language, path);
|
||||
available_before |= !sc.require_root_marker && server.status() != ide_core::event::ServerStatus::Missing;
|
||||
available_before |= !sc.require_root_marker && !server.gave_up();
|
||||
out.push(server);
|
||||
}
|
||||
out
|
||||
@@ -748,9 +753,14 @@ impl LspManager {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
// A server dropped meanwhile (disabled in settings, shadowed by another) must not come
|
||||
// back as a process nothing lists, serves or stops: a crash backoff can outlive it.
|
||||
if !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
return;
|
||||
}
|
||||
let workspace = is_local_jdtls(&server.config).then(|| self.jdtls_workspace_state());
|
||||
if let Some(state) = &workspace {
|
||||
if state.transitioning || !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
if state.transitioning {
|
||||
return;
|
||||
}
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
@@ -861,24 +871,28 @@ impl LspManager {
|
||||
}
|
||||
Err(LaunchError::Config(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
server.fail_start(
|
||||
if server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Misconfigured,
|
||||
Some(message),
|
||||
);
|
||||
) {
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(LaunchError::Failed(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
server.fail_start(
|
||||
if server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Crashed,
|
||||
Some(message),
|
||||
);
|
||||
) {
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -920,9 +934,18 @@ impl LspManager {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: initialize failed: {e}", server.name);
|
||||
if matches!(e, RpcError::Closed) {
|
||||
match e {
|
||||
// The server is exiting: let it, so the crash report has its exit code and last output.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
RpcError::Closed => {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
}
|
||||
RpcError::Timeout => {
|
||||
server.set_start_error(
|
||||
generation,
|
||||
format!("it did not answer initialize within {} s", INITIALIZE_TIMEOUT.as_secs()),
|
||||
);
|
||||
}
|
||||
e => server.set_start_error(generation, format!("initialize failed ({e})")),
|
||||
}
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
// Treated like a crash so transient failures retry with backoff.
|
||||
@@ -1008,11 +1031,15 @@ impl LspManager {
|
||||
self.forget(std::slice::from_ref(&server));
|
||||
return;
|
||||
}
|
||||
if let Some(delay) = delay {
|
||||
tokio::time::sleep(delay).await;
|
||||
if server.is_idle_gen(generation) {
|
||||
self.ensure_started(&server);
|
||||
match delay {
|
||||
Some(delay) => {
|
||||
tokio::time::sleep(delay).await;
|
||||
if server.is_idle_gen(generation) {
|
||||
self.ensure_started(&server);
|
||||
}
|
||||
}
|
||||
// Gave up (crashed repeatedly, or its build expired): the fallbacks it hid take over.
|
||||
None => self.attach_fallbacks(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1458,6 +1485,28 @@ impl LspManager {
|
||||
result
|
||||
}
|
||||
|
||||
/// [`Self::call`], asked again when the server withdrew the answer because the text changed
|
||||
/// (`ContentModified`, which arrives as a cancellation as no cancel token is passed).
|
||||
/// The client advertises `retryOnContentModified` for the methods that use it.
|
||||
async fn call_retrying<P: Serialize + Clone, R: DeserializeOwned>(
|
||||
&self,
|
||||
t: &Target,
|
||||
method: &str,
|
||||
params: P,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<R> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
match self.call(t, method, params.clone(), None, timeout).await {
|
||||
Err(e) if e.kind == ErrorKind::Cancelled && attempt < STALE_RETRIES => {
|
||||
attempt += 1;
|
||||
tokio::time::sleep(STALE_RETRY_DELAY).await;
|
||||
}
|
||||
result => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- features ----
|
||||
pub async fn completion(
|
||||
&self,
|
||||
@@ -2892,11 +2941,10 @@ impl LspManager {
|
||||
async move {
|
||||
(
|
||||
t,
|
||||
self.call::<_, Option<Vec<lsp::InlayHint>>>(
|
||||
self.call_retrying::<_, Option<Vec<lsp::InlayHint>>>(
|
||||
t,
|
||||
"textDocument/inlayHint",
|
||||
params,
|
||||
None,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await,
|
||||
@@ -2927,7 +2975,8 @@ impl LspManager {
|
||||
self.ready_targets(&snap.servers, |c| c.semantic_tokens_full() && c.semantic_legend.is_some()).await;
|
||||
let Some(t) = targets.first() else { return Ok(None) };
|
||||
let params = json!({ "textDocument": snap.ident() });
|
||||
let r: Option<Value> = self.call(t, "textDocument/semanticTokens/full", params, None, DEFAULT_TIMEOUT).await?;
|
||||
let r: Option<Value> =
|
||||
self.call_retrying(t, "textDocument/semanticTokens/full", params, DEFAULT_TIMEOUT).await?;
|
||||
let Some(data) = r.as_ref().and_then(|v| v.get("data")).and_then(Value::as_array) else { return Ok(None) };
|
||||
let data: Vec<i64> = data.iter().map(|n| n.as_i64().unwrap_or(0)).collect();
|
||||
let (enc, legend) = {
|
||||
@@ -2948,7 +2997,7 @@ impl LspManager {
|
||||
let params = json!({ "textDocument": snap.ident() });
|
||||
async move {
|
||||
let lenses = self
|
||||
.call::<_, Option<Vec<lsp::CodeLens>>>(t, "textDocument/codeLens", params, None, DEFAULT_TIMEOUT)
|
||||
.call_retrying::<_, Option<Vec<lsp::CodeLens>>>(t, "textDocument/codeLens", params, DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
let lenses = lenses.unwrap_or_default();
|
||||
if !t.caps().has_sub("codeLensProvider", "resolveProvider") {
|
||||
@@ -3876,26 +3925,35 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> {
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|s| s.id == server)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?;
|
||||
if !s.root.is_dir() {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the workspace root of {} is gone: {}",
|
||||
s.name,
|
||||
s.root.display()
|
||||
)));
|
||||
}
|
||||
self.stop_instance(&s, false).await;
|
||||
s.reset_for_restart();
|
||||
self.ensure_started(&s);
|
||||
let s = {
|
||||
// Only the restart itself holds the lock: a start can take minutes (a Gradle or
|
||||
// Maven import), and `reconfigure` and `shutdown_all` wait for it.
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|s| s.id == server)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?;
|
||||
if !s.root.is_dir() {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the workspace root of {} is gone: {}",
|
||||
s.name,
|
||||
s.root.display()
|
||||
)));
|
||||
}
|
||||
self.stop_instance(&s, false).await;
|
||||
s.reset_for_restart();
|
||||
self.ensure_started(&s);
|
||||
// Started again: it shadows the fallbacks that took over while it was down.
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
s
|
||||
};
|
||||
match s.wait_ready(Duration::from_secs(60)).await {
|
||||
Some(_) => Ok(()),
|
||||
// Still importing the project: the restart worked, the server is just not ready yet.
|
||||
None if s.is_starting() => Ok(()),
|
||||
None => {
|
||||
Err(IdeError::unavailable(s.status_message().unwrap_or_else(|| format!("{} failed to start", s.name))))
|
||||
}
|
||||
|
||||
@@ -136,7 +136,8 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
return Some((entry.clone(), text));
|
||||
}
|
||||
}
|
||||
// A facade renamed with `@file:JvmName("…")`.
|
||||
// A facade renamed with `@file:JvmName("…")`, or a class declared in a file named otherwise
|
||||
// (`UserDto` in `Models.kt`).
|
||||
let kotlin_files: Vec<String> = zip
|
||||
.file_names()
|
||||
.filter_map(|n| n.ok().map(|n| n.into_owned()))
|
||||
@@ -145,7 +146,7 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
let jvm_name = format!("@file:JvmName(\"{outer}\")");
|
||||
for entry in kotlin_files {
|
||||
if let Some(text) = read(&entry)
|
||||
&& text.text.contains(&jvm_name)
|
||||
&& (text.text.contains(&jvm_name) || declares_type(&text.text, outer))
|
||||
{
|
||||
return Some((entry, text));
|
||||
}
|
||||
@@ -153,6 +154,26 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `text` declares a type called `name` (`class`, `interface`, `object`, `typealias`):
|
||||
/// how a Kotlin file that is not named after its class is recognised.
|
||||
fn declares_type(text: &str, name: &str) -> bool {
|
||||
const KINDS: [&str; 4] = ["class", "interface", "object", "typealias"];
|
||||
text.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("//") || line.starts_with("/*") || line.starts_with('*') {
|
||||
return false;
|
||||
}
|
||||
let mut words =
|
||||
line.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$' || c == '`')).filter(|w| !w.is_empty());
|
||||
let mut previous = words.next();
|
||||
words.any(|word| {
|
||||
let hit = previous.is_some_and(|p| KINDS.contains(&p)) && word.trim_matches('`') == name;
|
||||
previous = Some(word);
|
||||
hit
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Where `name` is declared in a Kotlin or Java source `text` (the start of the
|
||||
/// name), else the start of the file.
|
||||
pub(crate) fn declaration_range(text: &str, name: &str) -> Range {
|
||||
@@ -245,6 +266,22 @@ pub(crate) fn folder_class(uri: &str) -> Option<(String, String)> {
|
||||
Some((package.replace('.', "/"), name.to_owned()))
|
||||
}
|
||||
|
||||
/// Kotlin sources larger than this are not searched for a class declared in a file named otherwise.
|
||||
const MAX_SOURCE: u64 = 1 << 20;
|
||||
|
||||
/// The files of the project's source trees: build output and ignored files are skipped.
|
||||
fn walk_sources(root: &Path) -> impl Iterator<Item = ignore::DirEntry> {
|
||||
ignore::WalkBuilder::new(root)
|
||||
.filter_entry(|e| {
|
||||
let output = matches!(e.file_name().to_str(), Some("target" | "build" | "out" | "node_modules"));
|
||||
// A package may be named `build`: only outside source trees is it output.
|
||||
!output || e.path().components().any(|c| c.as_os_str() == "src")
|
||||
})
|
||||
.build()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
|
||||
}
|
||||
|
||||
/// The project's source of `class` (`Name`, `Outer$Inner`, `FileKt`) in `package`
|
||||
/// (`/`-separated): a `.kt` / `.java` file of that name declaring the package. Build
|
||||
/// output and ignored files are skipped.
|
||||
@@ -255,19 +292,24 @@ pub(crate) fn project_class_source(root: &Path, package: &str, class: &str) -> O
|
||||
names.push(format!("{file}.kt"));
|
||||
}
|
||||
let package = package.replace('/', ".");
|
||||
let mut found: Vec<PathBuf> = ignore::WalkBuilder::new(root)
|
||||
.filter_entry(|e| {
|
||||
let output = matches!(e.file_name().to_str(), Some("target" | "build" | "out" | "node_modules"));
|
||||
// A package may be named `build`: only outside source trees is it output.
|
||||
!output || e.path().components().any(|c| c.as_os_str() == "src")
|
||||
})
|
||||
.build()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
|
||||
let mut found: Vec<PathBuf> = walk_sources(root)
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| names.iter().any(|name| name == n)))
|
||||
.map(|e| e.into_path())
|
||||
.filter(|p| std::fs::read_to_string(p).is_ok_and(|text| declared_package(&text) == package))
|
||||
.collect();
|
||||
// A class declared in a file named otherwise (`UserDto` in `Models.kt`).
|
||||
if found.is_empty() {
|
||||
found = walk_sources(root)
|
||||
.filter(|e| {
|
||||
e.path().extension().is_some_and(|x| x == "kt") && e.metadata().is_ok_and(|m| m.len() <= MAX_SOURCE)
|
||||
})
|
||||
.map(|e| e.into_path())
|
||||
.filter(|p| {
|
||||
std::fs::read_to_string(p)
|
||||
.is_ok_and(|text| declared_package(&text) == package && declares_type(&text, outer))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// The directory matching the package first, then by name order.
|
||||
found.sort_by_key(|p| {
|
||||
let dir = p.parent().map(|d| d.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
@@ -358,6 +400,10 @@ mod tests {
|
||||
"@file:JvmName(\"BootUtils\")\npackage org.springframework.boot\nfun util() {}",
|
||||
),
|
||||
("org/springframework/boot/Builder.kt", "class Builder"),
|
||||
(
|
||||
"org/springframework/boot/Models.kt",
|
||||
"package org.springframework.boot\n\n// class NotMe\ndata class UserDto(val id: Int)\nenum class Role { A }\ninterface Repo\nobject Registry\ntypealias Ids = List<Int>\n",
|
||||
),
|
||||
("Root.java", "class Root {}"),
|
||||
];
|
||||
jar(&m2.join("spring-boot-3.5.16.jar"), &[]);
|
||||
@@ -373,6 +419,11 @@ mod tests {
|
||||
("org/springframework/boot/SpringApplication$Running.class", "SpringApplication.java"),
|
||||
("org/springframework/boot/BootUtils.class", "Renamed.kt"),
|
||||
("org/springframework/boot/Builder.class", "Builder.kt"),
|
||||
// Declared in a file that is not named after them.
|
||||
("org/springframework/boot/UserDto.class", "Models.kt"),
|
||||
("org/springframework/boot/Role$A.class", "Models.kt"),
|
||||
("org/springframework/boot/Repo.class", "Models.kt"),
|
||||
("org/springframework/boot/Registry.class", "Models.kt"),
|
||||
("Root.class", "Root.java"),
|
||||
] {
|
||||
let (source_uri, text) =
|
||||
@@ -385,6 +436,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
|
||||
assert!(class_source(&uri("org/springframework/boot/NotMe.class"), None).is_none(), "only in a comment");
|
||||
}
|
||||
// No sources jar: nothing to show from here.
|
||||
let lone = tmp.path().join("lone/x-1.jar");
|
||||
@@ -513,6 +565,11 @@ mod tests {
|
||||
write("src/main/kotlin/com/x/build/Tool.kt", "package com.x.build\n\nclass Tool\n");
|
||||
write("src/main/kotlin/flat/Elsewhere.kt", "");
|
||||
write("src/main/kotlin/Loose.kt", "package flat\n\nobject Loose\n");
|
||||
write(
|
||||
"src/main/kotlin/test001/Models.kt",
|
||||
"package test001\n\n// class NotMe\ndata class UserDto(val id: Int)\n",
|
||||
);
|
||||
write("src/main/kotlin/other/Models.kt", "package other\n\ndata class UserDto(val name: String)\n");
|
||||
let source = |package: &str, class: &str| {
|
||||
project_class_source(root, package, class).map(|p| p.strip_prefix(root).unwrap().to_owned())
|
||||
};
|
||||
@@ -523,6 +580,10 @@ mod tests {
|
||||
assert_eq!(source("com/x/build", "Tool"), Some(PathBuf::from("src/main/kotlin/com/x/build/Tool.kt")));
|
||||
assert_eq!(source("flat", "Loose"), Some(PathBuf::from("src/main/kotlin/Loose.kt")), "any directory");
|
||||
assert_eq!(source("test001", "Missing"), None);
|
||||
// Declared in a file named otherwise: the one of the package, not another package's.
|
||||
assert_eq!(source("test001", "UserDto"), Some(PathBuf::from("src/main/kotlin/test001/Models.kt")));
|
||||
assert_eq!(source("other", "UserDto"), Some(PathBuf::from("src/main/kotlin/other/Models.kt")));
|
||||
assert_eq!(source("test001", "NotMe"), None, "only in a comment");
|
||||
assert_eq!(declared_package("package a.b.c;\n"), "a.b.c");
|
||||
assert_eq!(declared_package("class A"), "");
|
||||
}
|
||||
|
||||
@@ -166,13 +166,10 @@ impl LspManager {
|
||||
let result_id = report.get("resultId").and_then(Value::as_str).map(str::to_owned);
|
||||
let items = match report.get("kind").and_then(Value::as_str) {
|
||||
Some("unchanged") => None,
|
||||
_ => match serde_json::from_value(report.get("items").cloned().unwrap_or(json!([]))) {
|
||||
Ok(items) => Some(items),
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: bad textDocument/diagnostic report: {e}", t.server.name);
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => Some(crate::handler::lenient_diagnostics(
|
||||
&t.server.name,
|
||||
report.get("items").cloned().unwrap_or(json!([])),
|
||||
)),
|
||||
};
|
||||
let shown = t.server.set_pulled(t.path.clone(), result_id, items);
|
||||
let enc = t.run.caps.read().encoding;
|
||||
|
||||
@@ -112,8 +112,11 @@ pub struct Server {
|
||||
status: Mutex<(ServerStatus, Option<String>)>,
|
||||
/// Consecutive crashes of short-lived instances.
|
||||
crashes: Mutex<usize>,
|
||||
/// When the current instance was launched.
|
||||
/// When the current instance became ready: only time spent running counts as uptime,
|
||||
/// not the time spent starting (an `initialize` that hangs for minutes is not a stable run).
|
||||
started: Mutex<Option<Instant>>,
|
||||
/// Why the current instance's `initialize` failed, for the crash report.
|
||||
start_error: Mutex<Option<(u64, String)>>,
|
||||
stderr: Mutex<VecDeque<String>>,
|
||||
pub progress: Mutex<HashMap<String, Progress>>,
|
||||
/// What the editor shows per path: the pushed and the pulled diagnostics together.
|
||||
@@ -177,6 +180,7 @@ impl Server {
|
||||
status: Mutex::new((ServerStatus::Stopped, None)),
|
||||
crashes: Mutex::new(0),
|
||||
started: Mutex::new(None),
|
||||
start_error: Mutex::new(None),
|
||||
stderr: Mutex::new(VecDeque::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
diagnostics: Mutex::new(HashMap::new()),
|
||||
@@ -257,7 +261,8 @@ impl Server {
|
||||
st.conn = Conn::Starting;
|
||||
st.restarting = false;
|
||||
*self.start_cancel.lock() = CancellationToken::new();
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
*self.started.lock() = None;
|
||||
*self.start_error.lock() = None;
|
||||
// A crash report quotes only this instance's output, not its predecessors'.
|
||||
self.stderr.lock().clear();
|
||||
self.import_state.send_replace(None);
|
||||
@@ -305,9 +310,15 @@ impl Server {
|
||||
}
|
||||
st.conn = Conn::Ready(run);
|
||||
*self.initializing.lock() = None;
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
|
||||
/// Records why instance `generation` failed to initialize, so its crash report says so.
|
||||
pub fn set_start_error(&self, generation: u64, reason: String) {
|
||||
*self.start_error.lock() = Some((generation, reason));
|
||||
}
|
||||
|
||||
/// Wakes requests waiting for the instance (after status and documents are in place).
|
||||
pub fn announce_ready(&self, generation: u64) {
|
||||
let st = self.state.lock();
|
||||
@@ -327,6 +338,13 @@ impl Server {
|
||||
matches!(self.state.lock().conn, Conn::Starting)
|
||||
}
|
||||
|
||||
/// The server will not run until something changes: its binary is missing, its
|
||||
/// configuration can't launch it, or it crashed too often (or its build expired).
|
||||
/// Servers that crashed and are about to restart are not given up on.
|
||||
pub fn gave_up(&self) -> bool {
|
||||
matches!(self.state.lock().conn, Conn::Missing | Conn::Failed)
|
||||
}
|
||||
|
||||
/// The instance told `initialized`, ready or not.
|
||||
pub fn instance(&self) -> Option<Running> {
|
||||
self.running().or_else(|| self.initializing.lock().clone())
|
||||
@@ -351,6 +369,13 @@ impl Server {
|
||||
self.running().filter(|r| r.generation == generation)
|
||||
}
|
||||
|
||||
/// Instance `generation` is starting or running: not stopped, crashed or replaced.
|
||||
/// Work it reports before it is ready (a Gradle import answers `initialize` late) still counts.
|
||||
pub fn is_live(&self, generation: u64) -> bool {
|
||||
let st = self.state.lock();
|
||||
st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_))
|
||||
}
|
||||
|
||||
/// Waits through startup or a planned restart; returns the instance once ready.
|
||||
pub async fn wait_ready(&self, timeout: Duration) -> Option<Running> {
|
||||
let mut rx = self.phase.subscribe();
|
||||
@@ -441,10 +466,12 @@ impl Server {
|
||||
}
|
||||
let tail = self.stderr_tail(5);
|
||||
let cfg = self.effective_config();
|
||||
let mut message = match (code, &cfg.connect) {
|
||||
(Some(c), _) => format!("{} exited with code {c}", self.name),
|
||||
(None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", self.name),
|
||||
(None, None) => format!("{} terminated unexpectedly", self.name),
|
||||
let start_error = self.start_error.lock().take().filter(|(g, _)| *g == generation).map(|(_, r)| r);
|
||||
let mut message = match (&start_error, code, &cfg.connect) {
|
||||
(Some(reason), _, _) => format!("{} failed to start: {reason}", self.name),
|
||||
(None, Some(c), _) => format!("{} exited with code {c}", self.name),
|
||||
(None, None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", self.name),
|
||||
(None, None, None) => format!("{} terminated unexpectedly", self.name),
|
||||
};
|
||||
if !tail.is_empty() {
|
||||
message.push_str(":\n");
|
||||
@@ -477,6 +504,7 @@ impl Server {
|
||||
/// Records a crash; returns the backoff before restarting, or `None` once
|
||||
/// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`]
|
||||
/// (a server that crashes on every start, however slowly, is not restarted forever).
|
||||
/// An instance that never became ready has run for no time at all.
|
||||
pub fn record_crash(&self) -> Option<Duration> {
|
||||
let uptime = self.started.lock().map(|t| t.elapsed()).unwrap_or_default();
|
||||
let mut crashes = self.crashes.lock();
|
||||
@@ -1039,6 +1067,38 @@ mod tests {
|
||||
assert!(s.is_busy(), "a reporting task keeps the server busy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_time_spent_ready_counts_as_uptime() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
for n in 1..=MAX_RESTARTS + 1 {
|
||||
// Never ready: crashes after a slow start count however long the start took.
|
||||
let generation = s.begin_start().unwrap();
|
||||
assert!(s.started.lock().is_none());
|
||||
s.set_start_error(generation, "it did not answer initialize within 300 s".into());
|
||||
let delay = s.on_closed(generation, &sink, None).unwrap();
|
||||
assert_eq!(delay.is_some(), n <= MAX_RESTARTS, "crash {n}");
|
||||
let message = s.status_message().unwrap();
|
||||
assert!(
|
||||
message.starts_with(&format!("{} failed to start: it did not answer initialize", s.name)),
|
||||
"{message}"
|
||||
);
|
||||
s.state.lock().conn = Conn::Idle;
|
||||
}
|
||||
assert!(matches!(s.state.lock().conn, Conn::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_is_live_from_its_start_until_it_is_retired() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
assert!(s.is_live(generation), "still starting: its progress (an import) is being watched");
|
||||
assert!(!s.is_live(generation + 1));
|
||||
assert!(s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, None));
|
||||
assert!(!s.is_live(generation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() {
|
||||
let s = server();
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
|
||||
if matches!(key.as_str(), "data" | "arguments") {
|
||||
continue;
|
||||
}
|
||||
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
|
||||
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri" | "target")
|
||||
&& let Some(uri) = value.as_str().and_then(escaped_uri)
|
||||
{
|
||||
*value = uri.into();
|
||||
@@ -247,6 +247,22 @@ mod tests {
|
||||
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
|
||||
}
|
||||
|
||||
/// `DocumentLink.target` is a URI too: one unescaped link fails the whole `documentLink` response.
|
||||
#[test]
|
||||
fn document_link_targets_are_escaped() {
|
||||
let range = serde_json::json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } });
|
||||
let mut links = serde_json::json!([
|
||||
{ "range": range, "target": "file:///D:/My Game/项目/a.xsd" },
|
||||
{ "range": range, "target": "https://example.com/a b" }
|
||||
]);
|
||||
assert!(serde_json::from_value::<Vec<lsp_types::DocumentLink>>(links.clone()).is_err());
|
||||
normalize_uris(&mut links);
|
||||
assert_eq!(links[0]["target"], "file:///D:/My%20Game/%E9%A1%B9%E7%9B%AE/a.xsd");
|
||||
assert_eq!(links[1]["target"], "https://example.com/a b", "other schemes are not touched");
|
||||
links[1]["target"] = "https://example.com/ab".into();
|
||||
assert_eq!(serde_json::from_value::<Vec<lsp_types::DocumentLink>>(links).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// #3089: jdtls's Introduce Variable edit for a project under a Chinese path names the file
|
||||
/// with the characters unescaped, and the resolved code action failed to deserialize.
|
||||
#[test]
|
||||
|
||||
@@ -146,6 +146,9 @@ impl Handler for MockHandler {
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
if let Some(error) = s.errors.lock().get("initialize").cloned() {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } }))
|
||||
}
|
||||
"shutdown" => Ok(Value::Null),
|
||||
@@ -2073,6 +2076,29 @@ async fn crash_restarts_and_reopens_documents() {
|
||||
assert_eq!(hover.contents.value, "hi");
|
||||
}
|
||||
|
||||
/// A restart during a long start (a project import) returns when the instance is up and
|
||||
/// running, and does not hold up `reconfigure` and `shutdown_all` meanwhile.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn restart_does_not_hold_the_reconfigure_lock_while_the_server_starts() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*s.initialize_gate.lock() = Some(gate.clone());
|
||||
let id = h.mgr.servers()[0].id;
|
||||
let restart = tokio::spawn({
|
||||
let mgr = h.mgr.clone();
|
||||
async move { mgr.restart(id).await }
|
||||
});
|
||||
eventually(|| gate.entered()).await;
|
||||
let reconfigured = tokio::time::timeout(Duration::from_secs(2), h.mgr.reconfigure()).await;
|
||||
gate.open();
|
||||
reconfigured.expect("a restart still waiting for the server to start must not block reconfigure");
|
||||
restart.await.unwrap().unwrap();
|
||||
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -2100,6 +2126,118 @@ async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
|
||||
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
|
||||
}
|
||||
|
||||
/// One malformed diagnostic costs only itself, not the file's other problems.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_malformed_diagnostic_does_not_drop_the_others() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (_, path) = h.open("a.mk", "one");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
let range = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":1}});
|
||||
h.launcher
|
||||
.state("mock")
|
||||
.peer()
|
||||
.notify(
|
||||
"textDocument/publishDiagnostics",
|
||||
json!({ "uri": uri, "diagnostics": [
|
||||
{ "range": range, "message": "first" },
|
||||
{ "message": "no range" },
|
||||
{ "range": range, "message": 7 },
|
||||
{ "range": range, "message": "last" }] }),
|
||||
)
|
||||
.unwrap();
|
||||
eventually(|| {
|
||||
h.events.lock().iter().any(|e| match e {
|
||||
IdeEvent::Diagnostics { items, .. } => {
|
||||
items.iter().map(|d| d.message.as_str()).collect::<Vec<_>>() == ["first", "last"]
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The `initialize` error is the crash report's reason, not "terminated unexpectedly", and
|
||||
/// a server that never gets past `initialize` gives up like any other crash loop.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_initialize_is_reported_with_its_reason_and_gives_up() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let s = h.launcher.state("mock");
|
||||
s.errors.lock().insert("initialize".into(), (-32603, "no workspace".into()));
|
||||
h.open("a.mk", "x");
|
||||
let count = |status| h.statuses("mock").iter().filter(|s| **s == status).count();
|
||||
eventually(|| count(ServerStatus::Crashed) == 4).await;
|
||||
let messages: Vec<String> = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
messages[0].contains("failed to start: initialize failed (server error -32603: no workspace)"),
|
||||
"{messages:?}"
|
||||
);
|
||||
assert!(messages[0].ends_with("Restarting…"));
|
||||
assert!(messages[3].ends_with("restart it manually."), "{messages:?}");
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 4, "initial start + 3 restarts");
|
||||
}
|
||||
|
||||
/// The client tells servers it retries semantic tokens, inlay hints and code lens that they
|
||||
/// withdrew with `ContentModified` (`staleRequestSupport`), so it must.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn withdrawn_editor_decoration_requests_are_asked_again() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "let x = 1");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let range = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":1}});
|
||||
let cases = [
|
||||
("textDocument/semanticTokens/full", json!({ "data": [0, 0, 1, 0, 0] })),
|
||||
("textDocument/inlayHint", json!([{ "position": {"line":0,"character":0}, "label": "x" }])),
|
||||
("textDocument/codeLens", json!([{ "range": range, "command": { "title": "t", "command": "c" } }])),
|
||||
];
|
||||
for (method, answer) in cases {
|
||||
s.errors.lock().insert(method.into(), (-32801, "content modified".into()));
|
||||
let mgr = h.mgr.clone();
|
||||
let request = tokio::spawn(async move {
|
||||
match method {
|
||||
"textDocument/semanticTokens/full" => mgr.semantic_tokens(doc).await.map(|r| r.is_some()),
|
||||
"textDocument/inlayHint" => {
|
||||
mgr.inlay_hints(doc, Range::new(Pos::new(0, 0), Pos::new(0, 9))).await.map(|v| v.len() == 1)
|
||||
}
|
||||
_ => mgr.code_lens(doc).await.map(|v| v.len() == 1),
|
||||
}
|
||||
});
|
||||
eventually(|| !s.requested(method).is_empty()).await;
|
||||
s.errors.lock().remove(method);
|
||||
s.respond(method, answer);
|
||||
let answered = tokio::time::timeout(Duration::from_secs(5), request).await.unwrap().unwrap();
|
||||
assert!(answered.unwrap_or(false), "{method} was not asked again");
|
||||
assert_eq!(s.requested(method).len(), 2, "{method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A server disabled in settings while its crash backoff is still sleeping stays gone: the
|
||||
/// pending restart must not bring it back as a process nothing lists or stops.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_server_disabled_during_its_crash_backoff_does_not_restart() {
|
||||
let h = harness(&[("mock", incremental_caps()), ("other", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
s.peer().kill();
|
||||
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "other").cloned().collect()));
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| h.removed("mock")).await;
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart after the server was dropped");
|
||||
}
|
||||
|
||||
/// The whole workspace root deleted: the server can never start again
|
||||
/// (its cwd is gone), so the crash drops the entry instead of restarting.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -2998,6 +3136,49 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
}
|
||||
|
||||
/// A server that is installed but cannot run (crashed for good, or its configuration
|
||||
/// can't launch it) hides its fallbacks no longer than one that is missing.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_take_over_from_a_preferred_one_that_crashed_for_good() {
|
||||
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
|
||||
let (doc, path) = h.open("a.mk", "x");
|
||||
let main = h.launcher.state("main");
|
||||
let count = |status| h.statuses("main").iter().filter(|s| **s == status).count();
|
||||
for n in 1..=4 {
|
||||
eventually(|| count(ServerStatus::Ready) == n).await;
|
||||
// Restarting after a crash is not giving up: the fallback stays away.
|
||||
assert!(h.statuses("alt").is_empty(), "alt started after crash {}", n - 1);
|
||||
main.peer().kill();
|
||||
eventually(|| count(ServerStatus::Crashed) == n).await;
|
||||
}
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
|
||||
// Restarting the preferred one by hand gives the documents back to it.
|
||||
let id = h.mgr.servers().into_iter().find(|s| s.name == "main").unwrap().id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
eventually(|| h.removed("alt")).await;
|
||||
assert_eq!(h.mgr.servers().iter().filter(|s| s.name == "alt").count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_take_over_from_a_preferred_one_that_cannot_be_launched() {
|
||||
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| {
|
||||
if cfg.name == "main" {
|
||||
cfg.launch_error = Some("needs JDK 21".into());
|
||||
}
|
||||
}));
|
||||
let (doc, path) = h.open("a.mk", "x");
|
||||
h.wait_status("main", ServerStatus::Misconfigured).await;
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_server_requiring_a_root_marker_serves_only_files_below_one() {
|
||||
// `framework` next to `main` (one instance for the whole project), like the
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
|
||||
0b77e7a414ec3b0dd1f41e5658b981f059dd842743dbe2f06e5d428f5b9570f9
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
|
||||
6f7d6527837c8e5c86c377479cceef5116decd61666b28f36dd11332e36e9934
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
|
||||
c1ac86a5b4d45b55013e6e00dcb83deda33b759ecc366c3fdf57739bdd22cbff
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
|
||||
e4d8d8f5f5849b912b2c0ebee6b91d60796c3070929d7698323e8ce4f200a38a
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
|
||||
e2e2fc44ad9cae9cf26a5bf706768d3786502f23db6994dc29695deb249ed73f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
|
||||
61e1f0c43bbfddc35dcf40685169eb273777ddca13da534626e1699b47e8eac3
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
|
||||
2ea1c1693db8638db996c9c47597f0c1635ef0a9c7dfb33685c515b7af95e6a6
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
|
||||
3c788f6092b229484a5d94fd0e6f4651afc88dd0ab1b22b067d84614a6a0c85e
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
|
||||
18273599cc459833d6a30185a5f0ba127cdb4e22bc054a973571e530eb3a85ca
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
|
||||
c9fafba362fa0745d7ee1685efe5582d432510003c04c98100eddd557bd1e2cf
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
|
||||
4121009913709edeca8c5cb52d88dfcc2b16867bc06859768f73c17cf9c456a2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
|
||||
b55bd202a06f5643c4f70abb6fb473fbe53af657f4e21a6d13581a23a726e085
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
|
||||
11a12228df67f9b94bd52375b5dddb2d955f7e5f88d7f9ea0a430b1db128f6ca
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
|
||||
553ce6c57052f8ff2ea2526e88762d5a0aee2489cda192fc7ab708766078ccf7
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
|
||||
7b43f8a2a06d7b2952f8d46727a4cce524fb2bdecee1035dad9c767261d276d0
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
|
||||
e994c69fda5eef94ae85d6dac5aec3d04fb6811c35c446c9d33236a449bd1c6c
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
|
||||
c37f6a7ea9018ca636d0fd486a9d91cd397158615410de9b837ca9b8f9f593b7
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
|
||||
1b298dfa5c1f2163d609ef0ccc3d42fafe78e69326002effe53abc4b82974749
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
|
||||
1e11a95e0909d18308d21a9a8448f8b2e427d240d5d8cd13d8ddbe6ba5009237
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
d11f9cb1130f3f3aaa89514bf47ec8ece9b2ca0edf1d82c11f80b262325f16be
|
||||
d5cd77130cc97cffdbc742f04e3e1196f14160107b636806c6ef3c9b40724e84
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
48ea4414469ebd70a89da8a5df7b7bddc4d32f5c50f1759ee3cea14558073c92
|
||||
a0f74430b0a7d9ff48524d7aae4f8a0bd0fc60511e2a951dde9991b9bf7d15a9
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
0c02303182be04ed3776fb532fd8b72e6057aa0fdb92b7d235000146fef4120e
|
||||
65508f6e25bd0efc85820ae7d0f9037b58c778fa61e8498c5b9a5055cc38f6fc
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8162a70e848172663463119e6357984a73e54bc71a99b2845621d0733cf9333e
|
||||
c86ed41d68c132f72eb8b5794f31b975492e5ad42e41b7823d1bf28ba118a262
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4524cab0ceac631153a945f6b76f7d40efd3c854740ae22c64580381e9532397
|
||||
3da1a09906d803234016b3f9c30a88b9b40bf3187fc73840ff60a2e05f504b48
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
a92f4def072ca4cc805414516ab4f046fc54bcf11394a5413a27ea151c156174
|
||||
39e7a805120ce6433750c32b1d670b3caba990f7b15aea866a29c23b01cba708
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
994fca61bc5a10f0229301406af76c188a9cb2f593db85a403c1ece746e9f7b5
|
||||
5ec9be607955ce2aa4d921884df17c49af3ce98945f8fa641cec9a7f1b4d8384
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
48eaf97f57cc08c3854a1effa37ebf29defca4782f76c7de1da6ef9a02c23441
|
||||
f05df5c2faf1718ba9702be4d979c367ea8445a854794bb21e8e36b211f18122
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
b896043cf261ff4bb19977f4648a736907fa897b8d4f0804077678a5895d1845
|
||||
548cb8321b63b0685049fcf796dbc8390fcb22f3c1ffcfa09452e5a63382d27d
|
||||
@@ -193,6 +193,12 @@ fn read_only_error(path: &Path) -> IdeError {
|
||||
IdeError::new(ErrorKind::PermissionDenied, format!("{} is read-only", path.display()))
|
||||
}
|
||||
|
||||
/// The synthetic key of an unsaved new-file buffer (`untitled:Untitled-1`);
|
||||
/// minted by [`Documents::open_untitled`], replaced by a real path on save.
|
||||
fn is_untitled(path: &Path) -> bool {
|
||||
path.to_str().is_some_and(|p| p.starts_with("untitled:"))
|
||||
}
|
||||
|
||||
impl Documents {
|
||||
pub fn new(sink: SharedSink, detect_language: Box<DetectLanguage>) -> Self {
|
||||
Self {
|
||||
@@ -529,6 +535,75 @@ impl Documents {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Opens (or re-references) an editable buffer without a file behind it:
|
||||
/// a new-file tab ("Untitled-1"). Keyed by an `untitled:` URI like virtual
|
||||
/// documents, so it never touches the disk until [`Documents::save_as`]
|
||||
/// gives it a path (which also re-detects the language). Encoding, BOM and
|
||||
/// line ending come from the file defaults. `name` re-references an
|
||||
/// existing buffer (`Some("untitled:Untitled-1")`); `None` mints the
|
||||
/// first free one.
|
||||
pub fn open_untitled(&self, name: Option<&str>) -> IdeResult<DocumentInfo> {
|
||||
let _operation = self.operations.lock();
|
||||
let key = name.map(PathBuf::from).unwrap_or_else(|| self.next_untitled_key());
|
||||
if let Some(info) = self.reopen(&key) {
|
||||
return Ok(info);
|
||||
}
|
||||
let defaults = self.file_defaults();
|
||||
let mut doc = Document::new("");
|
||||
doc.set_line_ending(defaults.line_ending);
|
||||
let state = DocState {
|
||||
path: key.clone(),
|
||||
doc,
|
||||
language: LanguageId::plain_text(),
|
||||
ui_mode: None,
|
||||
encoding: defaults.encoding.clone(),
|
||||
has_bom: defaults.encoding == "utf-8" && defaults.utf8_bom,
|
||||
native_to_ascii: false,
|
||||
read_only: false,
|
||||
large: false,
|
||||
editorconfig: None,
|
||||
observed: true,
|
||||
binary: false,
|
||||
dirty: false,
|
||||
can_reload_encoding: false,
|
||||
encoding_chosen: false,
|
||||
disk_mtime: 0.0,
|
||||
disk_len: 0,
|
||||
closed: false,
|
||||
uri: Some(key.to_string_lossy().into_owned()),
|
||||
};
|
||||
let slot: Slot = Arc::new(Mutex::new(state));
|
||||
let st = slot.lock();
|
||||
let id = {
|
||||
let mut reg = self.registry.lock();
|
||||
if reg.by_key.contains_key(&key) {
|
||||
// Lost a race with a concurrent open of the same untitled buffer.
|
||||
drop(reg);
|
||||
drop(st);
|
||||
return self.reopen(&key).ok_or_else(|| IdeError::internal("document vanished while opening"));
|
||||
}
|
||||
let id = DocId::next();
|
||||
reg.by_key.insert(key.clone(), id);
|
||||
reg.docs.insert(id, RegEntry { slot: slot.clone(), refs: 1, keys: vec![key] });
|
||||
id
|
||||
};
|
||||
let info = Self::info(id, &st);
|
||||
for o in self.observers() {
|
||||
o.opened(&info);
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// The first free `untitled:Untitled-N` key.
|
||||
fn next_untitled_key(&self) -> PathBuf {
|
||||
let reg = self.registry.lock();
|
||||
let mut n = 1;
|
||||
while reg.by_key.contains_key(&PathBuf::from(format!("untitled:Untitled-{n}"))) {
|
||||
n += 1;
|
||||
}
|
||||
PathBuf::from(format!("untitled:Untitled-{n}"))
|
||||
}
|
||||
|
||||
fn reopen(&self, path: &Path) -> Option<DocumentInfo> {
|
||||
let slot = {
|
||||
let mut reg = self.registry.lock();
|
||||
@@ -740,6 +815,90 @@ impl Documents {
|
||||
})
|
||||
}
|
||||
|
||||
/// Save As for an untitled buffer: writes it to `target` (creating missing
|
||||
/// parent folders) and turns it into a regular file document — the buffer
|
||||
/// keeps its identity (undo history, version), the registry is re-keyed and
|
||||
/// the language is re-detected from the new path. The target path must not
|
||||
/// be open as another document.
|
||||
pub fn save_as(&self, doc: DocId, target: &Path) -> IdeResult<DocumentInfo> {
|
||||
let _operation = self.operations.lock();
|
||||
// The caller picks a real file path; an `untitled:` key is never one
|
||||
// (and must not be resolved against the working directory either).
|
||||
if is_untitled(target) {
|
||||
return Err(IdeError::invalid("cannot save over an untitled buffer"));
|
||||
}
|
||||
let target = absolute(target);
|
||||
{
|
||||
let reg = self.registry.lock();
|
||||
if let Some(other) = Self::lookup(®, &target)
|
||||
&& other != doc
|
||||
{
|
||||
return Err(IdeError::invalid(format!("{} is already open", target.display())));
|
||||
}
|
||||
}
|
||||
self.with_doc(doc, |st| {
|
||||
if st.uri.is_none() || !is_untitled(&st.path) {
|
||||
return Err(IdeError::invalid("only an untitled document can be saved to a new path"));
|
||||
}
|
||||
let text = st.doc.text_for_save();
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mtime = if st.native_to_ascii {
|
||||
ide_vfs::save_properties_text(&target, &text)?
|
||||
} else {
|
||||
ide_vfs::save_text(&target, &text, &st.encoding, st.has_bom)?
|
||||
};
|
||||
// Re-key the registry before anyone opens the new path in parallel.
|
||||
let keys = keys_for(&target);
|
||||
{
|
||||
let mut reg = self.registry.lock();
|
||||
let old_keys = match reg.docs.get_mut(&doc) {
|
||||
Some(entry) => std::mem::take(&mut entry.keys),
|
||||
None => return Err(IdeError::not_found("document closed while saving")),
|
||||
};
|
||||
for k in &old_keys {
|
||||
if reg.by_key.get(k) == Some(&doc) {
|
||||
reg.by_key.remove(k);
|
||||
}
|
||||
}
|
||||
for k in &keys {
|
||||
reg.by_key.insert(k.clone(), doc);
|
||||
}
|
||||
if let Some(entry) = reg.docs.get_mut(&doc) {
|
||||
entry.keys = keys;
|
||||
}
|
||||
}
|
||||
let old_path = std::mem::replace(&mut st.path, target.clone());
|
||||
st.uri = None;
|
||||
let defaults = self.file_defaults();
|
||||
let first_line = st.doc.text();
|
||||
let first_line = first_line.lines().next();
|
||||
let language = (self.detect_language)(&st.path, first_line);
|
||||
let ui_mode = (self.detect_ui_mode)(&st.path, &language);
|
||||
let language_changed = language != st.language;
|
||||
st.language = language.clone();
|
||||
st.ui_mode = ui_mode;
|
||||
st.editorconfig = if defaults.editorconfig { self.editorconfig.resolve(&st.path) } else { None };
|
||||
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
st.disk_mtime = mtime;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
self.set_dirty(doc, st, false);
|
||||
let info = Self::info(doc, st);
|
||||
if st.observed {
|
||||
for o in self.observers() {
|
||||
o.closed(doc, &old_path);
|
||||
o.opened(&info);
|
||||
o.saved(doc, &st.path, &st.doc.text());
|
||||
}
|
||||
}
|
||||
if language_changed {
|
||||
self.sink.emit(IdeEvent::DocLanguage { doc, language, ui_mode: st.ui_mode.clone() });
|
||||
}
|
||||
Ok(info)
|
||||
})
|
||||
}
|
||||
|
||||
/// Saves every dirty document; reports the first failure after trying all.
|
||||
pub fn save_all(&self) -> IdeResult<()> {
|
||||
let ids: Vec<DocId> = self.registry.lock().docs.keys().copied().collect();
|
||||
@@ -894,9 +1053,12 @@ impl Documents {
|
||||
/// is kept and the next save writes it in the new encoding.
|
||||
pub fn set_encoding(&self, doc: DocId, encoding: &str, reload: bool) -> IdeResult<DocumentEncoding> {
|
||||
self.with_doc(doc, |st| {
|
||||
if st.uri.is_some() {
|
||||
if st.uri.is_some() && !is_untitled(&st.path) {
|
||||
return Err(read_only_error(&st.path));
|
||||
}
|
||||
if reload && st.uri.is_some() {
|
||||
return Err(IdeError::invalid("save the file before reloading it with another encoding"));
|
||||
}
|
||||
ide_vfs::encode("", encoding, false)?;
|
||||
if reload {
|
||||
if st.dirty {
|
||||
@@ -937,6 +1099,42 @@ impl Documents {
|
||||
self.with_doc(doc, |st| Ok(st.uri.clone()))
|
||||
}
|
||||
|
||||
/// Whether this is an untitled (new-file) buffer: editable, no file behind
|
||||
/// it until [`Documents::save_as`].
|
||||
pub fn is_untitled(&self, doc: DocId) -> IdeResult<bool> {
|
||||
self.with_doc(doc, |st| Ok(is_untitled(&st.path)))
|
||||
}
|
||||
|
||||
/// The open untitled buffers: `(key, text, dirty)` — they belong to the
|
||||
/// window rather than a project, and [`Documents::restore_untitled_buffer`]
|
||||
/// carries them into a project's fresh document registry.
|
||||
pub fn untitled_buffers(&self) -> Vec<(String, String, bool)> {
|
||||
let slots: Vec<Slot> = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect();
|
||||
let mut out = Vec::new();
|
||||
for slot in slots {
|
||||
let st = slot.lock();
|
||||
if !st.closed && is_untitled(&st.path) {
|
||||
out.push((st.path.to_string_lossy().into_owned(), st.doc.text(), st.dirty));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Recreates an untitled buffer carried over from the window's previous
|
||||
/// project: same key, same text, same dirty flag.
|
||||
pub fn restore_untitled_buffer(&self, key: &str, text: &str, dirty: bool) -> IdeResult<()> {
|
||||
let info = self.open_untitled(Some(key))?;
|
||||
if info.text != text {
|
||||
self.replace_all(info.id, text)?;
|
||||
}
|
||||
// `replace_all` marks the buffer dirty; the carried flag is authoritative.
|
||||
self.with_doc(info.id, |st| {
|
||||
self.set_dirty(info.id, st, dirty);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -967,7 +1165,7 @@ impl Documents {
|
||||
/// 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| {
|
||||
if st.uri.is_some() || st.read_only {
|
||||
if st.read_only || (st.uri.is_some() && !is_untitled(&st.path)) {
|
||||
return Err(read_only_error(&st.path));
|
||||
}
|
||||
if st.encoding != "utf-8" || st.native_to_ascii {
|
||||
|
||||
@@ -1004,3 +1004,104 @@ fn properties_ascii_noop_and_charset_conversion() {
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, "a=世界😀");
|
||||
assert!(!f.docs.is_dirty(doc.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untitled_buffers_are_editable_and_saved_to_a_chosen_path() {
|
||||
let f = fixture();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.line_ending = LineEnding::Lf;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
let info = f.docs.open_untitled(None).unwrap();
|
||||
assert_eq!(info.path, PathBuf::from("untitled:Untitled-1"));
|
||||
assert_eq!(info.uri.as_deref(), Some("untitled:Untitled-1"));
|
||||
assert_eq!(info.language, LanguageId::plain_text());
|
||||
assert!(!info.read_only && !info.dirty && !info.large);
|
||||
|
||||
// Editing works; nothing is written anywhere.
|
||||
let version = f.docs.edit(info.id, info.version, change(0, 0, "fn main() {}\n")).unwrap();
|
||||
assert!(f.docs.is_dirty(info.id).unwrap());
|
||||
f.docs.save(info.id).unwrap();
|
||||
assert!(f.docs.is_dirty(info.id).unwrap(), "an untitled buffer has no file to save to");
|
||||
|
||||
// Save As: the buffer reaches the disk and becomes a regular file document
|
||||
// (same id, re-keyed, language re-detected, missing parents created).
|
||||
let target = f.root.join("new/nested/main.rs");
|
||||
let saved = f.docs.save_as(info.id, &target).unwrap();
|
||||
assert_eq!(saved.id, info.id);
|
||||
assert_eq!(saved.path, target);
|
||||
assert_eq!(saved.uri, None);
|
||||
assert_eq!(saved.language, LanguageId::new("rust"));
|
||||
assert!(!saved.dirty);
|
||||
assert_eq!(fs::read_to_string(&target).unwrap(), "fn main() {}\n");
|
||||
assert_eq!(f.docs.by_path(&target), Some(info.id));
|
||||
assert_eq!(f.docs.by_path(Path::new("untitled:Untitled-1")), None);
|
||||
f.docs.edit(info.id, version, change(0, 0, "// hi\n")).unwrap();
|
||||
f.docs.save(info.id).unwrap();
|
||||
assert!(!f.docs.is_dirty(info.id).unwrap());
|
||||
assert_eq!(fs::read_to_string(&target).unwrap(), "// hi\nfn main() {}\n");
|
||||
|
||||
// Re-opening the file references the same buffer (split editors share it).
|
||||
let again = f.docs.open(&target).unwrap();
|
||||
assert_eq!(again.id, info.id);
|
||||
f.docs.close(again.id);
|
||||
assert!(f.docs.snapshot(info.id).is_ok());
|
||||
f.docs.close(info.id);
|
||||
assert!(f.docs.snapshot(info.id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_rejects_paths_opened_elsewhere_and_reuses_free_names() {
|
||||
let f = fixture();
|
||||
let first = f.root.join("taken.txt");
|
||||
fs::write(&first, "taken").unwrap();
|
||||
let taken = f.docs.open(&first).unwrap();
|
||||
|
||||
let a = f.docs.open_untitled(None).unwrap();
|
||||
let b = f.docs.open_untitled(None).unwrap();
|
||||
assert_eq!(a.path, PathBuf::from("untitled:Untitled-1"));
|
||||
assert_eq!(b.path, PathBuf::from("untitled:Untitled-2"));
|
||||
f.docs.edit(a.id, a.version, change(0, 0, "draft")).unwrap();
|
||||
|
||||
let err = f.docs.save_as(a.id, &first).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
assert!(f.docs.is_dirty(a.id).unwrap(), "nothing was written");
|
||||
assert_eq!(fs::read_to_string(&first).unwrap(), "taken");
|
||||
|
||||
let err = f.docs.save_as(a.id, Path::new("untitled:Untitled-2")).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
|
||||
// The smallest free name is minted again after a buffer closes.
|
||||
f.docs.close(a.id);
|
||||
let c = f.docs.open_untitled(None).unwrap();
|
||||
assert_eq!(c.path, PathBuf::from("untitled:Untitled-1"));
|
||||
|
||||
// Non-untitled documents cannot be re-keyed.
|
||||
let err = f.docs.save_as(taken.id, &f.root.join("other.txt")).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untitled_buffers_take_file_defaults_and_refuse_encoding_reloads() {
|
||||
let f = fixture();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.line_ending = LineEnding::CrLf;
|
||||
defaults.encoding = "utf-8".into();
|
||||
defaults.utf8_bom = true;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
let info = f.docs.open_untitled(None).unwrap();
|
||||
assert_eq!(info.line_ending, LineEnding::CrLf);
|
||||
assert!(info.has_bom);
|
||||
assert!(!info.can_reload_encoding, "there is no file to re-read yet");
|
||||
|
||||
// Converting the encoding of the unsaved text works; re-decoding does not.
|
||||
let encoding = f.docs.set_encoding(info.id, "utf-16le", false).unwrap();
|
||||
assert_eq!(encoding.encoding, "utf-16le");
|
||||
let err = f.docs.set_encoding(info.id, "utf-8", true).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
|
||||
// Disk syncs never touch an untitled buffer.
|
||||
f.docs.edit(info.id, info.version, change(0, 0, "draft")).unwrap();
|
||||
f.docs.sync_after_disk_change();
|
||||
assert!(f.docs.is_dirty(info.id).unwrap());
|
||||
assert_eq!(f.docs.snapshot(info.id).unwrap().text, "draft");
|
||||
}
|
||||
@@ -88,6 +88,8 @@ Once an agent has run a task, new tasks start in its mode that approves everythi
|
||||
|
||||
Click **Show changes** in the task header to list the files the task changed. Click a file to open a diff between the task's starting point and now. For a worktree task, the changes live on its `agent/…` branch: open a terminal there with **Open in Terminal**, and merge or cherry-pick the branch with the Git tools (see [Version control](/en/guide/vcs)) when you are happy with it.
|
||||
|
||||
Drag the pane's left edge to resize it. The width applies to every task and is remembered across restarts.
|
||||
|
||||
### Context from the editor
|
||||
|
||||
- **Send to Agent** in the editor and Project view context menus adds the selection or the selected files as `@` references to the message you typed last. It has no default shortcut; assign one in **Settings › Keymap**.
|
||||
|
||||
@@ -136,6 +136,11 @@ buttons can act on what the user is editing.
|
||||
Toolbar buttons: `View::new(body).button(Button::new("refresh", "Refresh").icon("refresh"))`
|
||||
and `.status("12 items")`.
|
||||
|
||||
An item or button `icon` is an IDE icon name, a registered plugin icon, or a small raster image as a `data:` URI
|
||||
(`data:image/png;base64,…`; PNG, JPEG, GIF or WebP, at most 64 KiB), drawn at the icon's size: an avatar or a pipeline
|
||||
status badge. Fetch the image yourself (a `net` grant) and pass it inline; the IDE never loads URLs for an icon. Older
|
||||
hosts show the generic file icon in its place.
|
||||
|
||||
### Events
|
||||
|
||||
`ViewEvent` carries `Button(id)`, `Activate(key)` (double click or Enter), `Select(key)`,
|
||||
@@ -364,6 +369,10 @@ host::open_content_tab(&ContentTab {
|
||||
})?;
|
||||
```
|
||||
|
||||
A Markdown tab (`language` `markdown`, or a `file_name` ending in `.md`) has the Markdown editor's layouts (Editor Only,
|
||||
Editor and Preview, Preview Only) and opens on the rendered preview when it is read-only, such as a merge request's
|
||||
description. Web links in the preview open in the browser.
|
||||
|
||||
## Decorations
|
||||
|
||||
Decorations (API 1.4, WIT `decorations.wit`) annotate editors and the Project tree. They
|
||||
|
||||
@@ -88,6 +88,8 @@ agent 运行过一个任务后,新任务会以它自动批准所有操作的
|
||||
|
||||
在任务标题栏中单击 **显示更改**,列出任务改动过的文件;单击文件可以打开它从任务开始到现在的 diff。worktree 任务的改动在它的 `agent/…` 分支上:用 **在终端中打开** 在那里打开终端,满意后用 Git 工具合并或 cherry-pick 这个分支(见 [版本控制](/zh/guide/vcs))。
|
||||
|
||||
拖动改动面板的左边缘可以调整宽度。宽度对所有任务生效,重启后保留。
|
||||
|
||||
### 从编辑器提供上下文
|
||||
|
||||
- 编辑器和项目视图右键菜单中的 **发送给 Agent** 会把选区或选中的文件作为 `@` 引用加到你最近输入的消息中。它默认没有快捷键,可以在 **设置 › 按键映射** 中分配。
|
||||
|
||||
@@ -126,6 +126,8 @@ register_plugin!(TodoFinder, views);
|
||||
|
||||
工具栏按钮:`View::new(body).button(Button::new("refresh", "Refresh").icon("refresh"))`,状态行:`.status("12 items")`。
|
||||
|
||||
条目和按钮的 `icon` 可以是 IDE 图标名、已注册的插件图标,或者以 `data:` URI 给出的小尺寸位图(`data:image/png;base64,…`,支持 PNG、JPEG、GIF、WebP,最大 64 KiB),按图标尺寸绘制,适合头像或流水线状态徽章。图片由插件自己获取(需要 `net` 授权)后内联传入,IDE 不会为图标加载 URL。较旧的宿主会在该位置显示通用文件图标。
|
||||
|
||||
### 事件
|
||||
|
||||
`ViewEvent` 包括 `Button(id)`、`Activate(key)`(双击或回车)、`Select(key)`、`Expand(key)`、`Collapse(key)`、
|
||||
@@ -329,6 +331,8 @@ host::open_content_tab(&ContentTab {
|
||||
})?;
|
||||
```
|
||||
|
||||
Markdown 标签页(`language` 为 `markdown`,或 `file_name` 以 `.md` 结尾)提供与 Markdown 编辑器相同的布局(仅编辑器、编辑器和预览、仅预览),只读时直接打开渲染后的预览,适合合并请求的描述这类内容。预览中的网页链接在浏览器中打开。
|
||||
|
||||
## 装饰
|
||||
|
||||
装饰(API 1.4,WIT `decorations.wit`)用于在编辑器和项目树中添加标注。需要 `project.read`,路径必须在打开的项目内。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { AgentTask } from "../src/bindings";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "composer-click",
|
||||
title: "Composer click task",
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
async function openTask(page: Page) {
|
||||
await page.evaluate(async (task) => {
|
||||
// These modules must load in the browser's Vite realm, not Playwright's Node process.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const ipcPath = "/src/platform/ipc.ts";
|
||||
const store = await import(storePath);
|
||||
const { commands } = await import(ipcPath);
|
||||
commands.agentChanges = async () => ({ status: "ok", data: [] });
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, [{ type: "agent", text: "Reply" }]]]),
|
||||
composers: new Map(),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
homeOpen: false,
|
||||
sessionLocation: "editor",
|
||||
});
|
||||
store.openTask(task.id);
|
||||
}, task);
|
||||
}
|
||||
|
||||
// Every row of the text area, down to the toolbar row, puts the caret in the message.
|
||||
test("clicking anywhere in the composer's text area focuses the message", async ({ workbench: page }) => {
|
||||
await openTask(page);
|
||||
const box = page.getByRole("textbox", { name: "Message", exact: true }).filter({ visible: true });
|
||||
await expect(box).toBeVisible();
|
||||
const composer = page.locator("[class*='_composer_']:visible").filter({ has: box });
|
||||
const bar = composer.locator("[class*='_bar_']");
|
||||
const frame = (await composer.boundingBox())!;
|
||||
const barTop = (await bar.boundingBox())!.y;
|
||||
const history = page.locator("[class*='_stream_']:visible");
|
||||
const missed: number[] = [];
|
||||
for (let y = Math.ceil(frame.y) + 1; y < barTop; y++) {
|
||||
await history.click({ position: { x: 12, y: 12 } });
|
||||
await expect(box).not.toBeFocused();
|
||||
await page.mouse.click(frame.x + frame.width / 2, y);
|
||||
if (!(await box.evaluate((element) => element === document.activeElement))) missed.push(y - frame.y);
|
||||
}
|
||||
expect(missed).toEqual([]);
|
||||
await page.keyboard.type("typed");
|
||||
await expect(box).toHaveText("typed");
|
||||
});
|
||||
@@ -88,6 +88,30 @@ async function seedTasks(page: Page, placement: Placement, status: AgentTask["st
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
|
||||
async function seedChanges(page: Page) {
|
||||
await page.evaluate(async (id) => {
|
||||
// Seed the store in the browser realm; a static import would run in Node.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
(await import(storePath)).useAgents.setState({
|
||||
changes: new Map([
|
||||
[
|
||||
id,
|
||||
[
|
||||
{
|
||||
path: "/dev/acme-api/file.ts",
|
||||
relPath: "file.ts",
|
||||
kind: "modified",
|
||||
added: 1,
|
||||
removed: 0,
|
||||
oldPath: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
}, firstTask.id);
|
||||
}
|
||||
|
||||
function messageBox(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message", exact: true }).filter({ visible: true });
|
||||
}
|
||||
@@ -345,27 +369,7 @@ test("docked Changes view returns to the task input without losing its selection
|
||||
await seedTasks(page, "toolWindow");
|
||||
const box = messageBox(page);
|
||||
const expected = await selectDraft(box, true);
|
||||
await page.evaluate(async (id) => {
|
||||
// Seed the store in the browser realm; a static import would run in Node.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
(await import(storePath)).useAgents.setState({
|
||||
changes: new Map([
|
||||
[
|
||||
id,
|
||||
[
|
||||
{
|
||||
path: "/dev/acme-api/file.ts",
|
||||
relPath: "file.ts",
|
||||
kind: "modified",
|
||||
added: 1,
|
||||
removed: 0,
|
||||
oldPath: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
}, firstTask.id);
|
||||
await seedChanges(page);
|
||||
await page.getByRole("button", { name: /1 changed file/ }).click();
|
||||
const changes = page.getByRole("complementary", { name: "Changes", exact: true });
|
||||
await expect(changes).toBeVisible();
|
||||
@@ -377,3 +381,25 @@ test("docked Changes view returns to the task input without losing its selection
|
||||
await expect(box).toHaveText(draft);
|
||||
await expect.poll(() => selection(box)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("dragging the Changes pane's edge resizes it for the next session too", async ({ workbench: page }) => {
|
||||
await seedTasks(page, "editor");
|
||||
await seedChanges(page);
|
||||
const changes = page.getByRole("complementary", { name: "Changes", exact: true });
|
||||
await expect(changes).toBeVisible();
|
||||
const handle = changes.getByRole("separator", { name: "Resize changes", exact: true });
|
||||
const before = (await changes.boundingBox())!.width;
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 120, box.y + box.height / 2, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => (await changes.boundingBox())!.width).toBeGreaterThan(before + 100);
|
||||
const widened = (await changes.boundingBox())!.width;
|
||||
|
||||
// The next session starts with the dragged width.
|
||||
await page.reload();
|
||||
await seedTasks(page, "editor");
|
||||
await seedChanges(page);
|
||||
await expect.poll(async () => Math.round((await changes.boundingBox())!.width)).toBe(Math.round(widened));
|
||||
});
|
||||
@@ -17,7 +17,7 @@ name = "kotlin-lsp"
|
||||
command = "kotlin-lsp"
|
||||
args = ["--stdio"]
|
||||
root-markers = ["settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle", "pom.xml"]
|
||||
install-hint = "Install JetBrains Kotlin LSP: brew install JetBrains/utils/kotlin-lsp (https://github.com/Kotlin/kotlin-lsp)"
|
||||
install-hint = "Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers (https://github.com/Kotlin/kotlin-lsp), or with Homebrew: brew install JetBrains/utils/kotlin-lsp"
|
||||
|
||||
[[language-servers]]
|
||||
name = "kotlin-language-server"
|
||||
|
||||
@@ -445,7 +445,7 @@ matching SDK. Released 1.6 plugins keep working. New in 1.7:
|
||||
two texts in the invoking window's diff viewer, for example a merge request's
|
||||
file before and after; `file_name` picks the syntax highlighting. Web views
|
||||
send `{ op: "open-diff", left, right, leftLabel?, rightLabel?, title?, fileName? }`.
|
||||
Commands and events only; each side at most 1 MiB.
|
||||
Commands and events only; each side at most 1 MiB (8 MiB on current hosts).
|
||||
|
||||
### API 1.5
|
||||
|
||||
@@ -669,7 +669,7 @@ then run `openidea-plugin check`.
|
||||
| `host::write_terminal(key, text)` | `views.write-terminal` | API 1.4: write only to this plugin's still-running terminal in this window; trusted project, commands/events only. |
|
||||
| `host::open_content_tab(tab)` | `views.open-content-tab` | API 1.4: the shared content editor protocol, without a web view; actions arrive as `content-tab-action`. |
|
||||
| `host::reply_content_tab(request_id, reply)` | `views.reply-content-tab` | API 1.4: reply to an owned action in the originating window. No capability; commands/events only. |
|
||||
| `host::open_diff(diff, file_name)` | `views.open-diff` | API 1.7: two texts in the invoking window's diff viewer; `file_name` (at most 256 characters) picks the highlighting. Commands/events only; each side at most 1 MiB. |
|
||||
| `host::open_diff(diff, file_name)` | `views.open-diff` | API 1.7: two texts in the invoking window's diff viewer; `file_name` (at most 256 characters) picks the highlighting. Commands/events only; each side at most 8 MiB (earlier hosts: 1 MiB), a larger side fails with `Invalid("diff too large: …")`. |
|
||||
| `RunConfigurations::configurations(root)` | export `run-configurations.configurations` | API 1.4: optional `plugin-run-configurations` world; SDK registration `run_configurations`. Full project list, refreshed on configuration listing. |
|
||||
| `TestResults::parse(config, chunk)` | export `test-results.parse` | API 1.4: optional `plugin-test-results` world; SDK registration `test_results`. Bounded output batches for `framework = "plugin"`; failures use built-in parsers. |
|
||||
| `TestResults::select_tests(config, tests)` | export `test-results.select-tests` | API 1.4: full replacement argv for test-tree name paths, or `None` when unsupported (the SDK default). Enables rerunning failed tests and running tree selections. |
|
||||
@@ -1883,8 +1883,9 @@ are forwarded to the originating desktop window.
|
||||
with the same content, action, reply and diff behavior. Its key is scoped to the
|
||||
plugin and window, independently of web views. Save and toolbar actions arrive
|
||||
as `content-tab-action`; answer with `host::reply_content_tab(request_id, reply)`.
|
||||
The host accepts replies only from that plugin and window. Content and diff text
|
||||
are limited to 1 Mi UTF-16 units, with at most 16 actions and one Save action.
|
||||
The host accepts replies only from that plugin and window. Content is limited to
|
||||
1 Mi UTF-16 units and each side of a diff to 8 Mi, with at most 16 actions and one
|
||||
Save action.
|
||||
|
||||
### Plugin-backed content editor tabs (API 1.3)
|
||||
|
||||
@@ -1913,7 +1914,10 @@ openidea.postMessage(JSON.stringify({
|
||||
are required. `key` (1–128 characters) defaults to `title` and is scoped to the
|
||||
calling plugin and view; `fileName` (1–256 characters, no path separators) and
|
||||
`language` (1–64 characters) select syntax highlighting and the tab's file icon.
|
||||
`readOnly` defaults to `false`. `actions` lists up to 16 toolbar buttons with
|
||||
A Markdown tab (`language` `markdown`, or a `fileName` ending in `.md`) has the
|
||||
Markdown file editor's layouts (Editor Only, Editor and Preview, Preview Only) and
|
||||
opens on the rendered preview when it is read-only; web links in the preview open
|
||||
in the browser. `readOnly` defaults to `false`. `actions` lists up to 16 toolbar buttons with
|
||||
unique `id`s (1–64 characters), `label`s (1–128 characters), an optional built-in
|
||||
`icon` name, `primary`, and `save` (at most one action may set `save: true`).
|
||||
Invalid requests send `content-tab-error { error }` through `openidea.onMessage`.
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
56008cb5a36b35b8975b95da971212c95c165de378b0c8b7f7b428d9d9332ec1
|
||||
93f5619378f0edaf2e78a95b38e92e6e14ffc4377aab425d575dbbcb30c7c3e4
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d87e571ebe16283bbffcc7525f69755d5faffb4693ff4a5183de67418976086b
|
||||
007134d609d842c6f57386e35a6725948ad17fdd6458a2e209398ee38d034a23
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
254da92cdb20bdf432b5bb4e90ef9bdfaff92eca7195fbff38127f47fa231e6e
|
||||
01d5abc7a65811684cac58e39d733218d5cde46681b7e57231897f6a07318133
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
50ea437aa2b99dc28afd51286fbac7e07d8386f826f222c517a8bd6e4f647a1a
|
||||
9b29d2a047e223ecd432fb90914c684f708eb8becba6cc72cd55462064d15956
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
28584de67687b8e34376cd391a020d4c246fd98a3b904e15f413b56a650b5f06
|
||||
0c017a96cee53de17cebb7324dde08f9fb932b17e0a077056f1d30d2eafffc9a
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
335fa8a14e0d517ef1f735b02c49d8d902a57895c862ca8240854bb758796928
|
||||
dfdb67dc602c53e5a6ce811d1b1c60de1cc933309270eb71aa2f58fef3d5099c
|
||||
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача