дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 14:15:39 +02:00
Порівняти коміти
29
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
54e3b63547 | ||
|
|
0af3bd00e7 | ||
|
|
951e81e065 | ||
|
|
023c0a6ee6 | ||
|
|
c5481e8fe4 | ||
|
|
dfacb45575 | ||
|
|
3363bb8a46 | ||
|
|
39f34ec22f | ||
|
|
34ae8acbf8 | ||
|
|
6bafcb2b5c | ||
|
|
a6ed65e6d1 | ||
|
|
009e61d27b | ||
|
|
a89c0dd229 | ||
|
|
ea644dcdb0 | ||
|
|
519c8ad8fc | ||
|
|
27fdbe71e5 | ||
|
|
d5cd5e7a55 | ||
|
|
c32a68c0b8 | ||
|
|
59cc84521f | ||
|
|
4faa37aaea | ||
|
|
aaf25d9f0d | ||
|
|
913ad6616c | ||
|
|
b80f0f9e39 | ||
|
|
460027fd4b | ||
|
|
33c5627bdb | ||
|
|
3a22a2d0f0 | ||
|
|
c169b8ab3c | ||
|
|
0dbce5d09e | ||
|
|
7fe6f5f58d |
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 {
|
||||
@@ -159,6 +162,15 @@ pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<
|
||||
.await
|
||||
}
|
||||
|
||||
/// Python class facts the inheritance inspections read: base names as written, a method's
|
||||
/// parameters, decorator semantics and its body shape. Tree-sitter answers; no LSP.
|
||||
pub async fn doc_python_facts(host: &Host, window: &str, doc: DocId) -> IdeResult<ide_language::PythonFacts> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
blocking(move || Ok(syntax.python_facts(&snap.language, &snap.text))).await
|
||||
}
|
||||
|
||||
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
|
||||
/// Large documents fall back to word / line / document ranges.
|
||||
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
|
||||
@@ -208,6 +220,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_python_facts", |c| async move { doc_python_facts(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_selection_ranges", |c| async move {
|
||||
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
|
||||
});
|
||||
|
||||
@@ -1472,6 +1472,15 @@ pub async fn lsp_type_hierarchy(
|
||||
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_type_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<HierarchyItem>> {
|
||||
host.project(window)?.lsp.prepare_type_hierarchy(doc, pos).await
|
||||
}
|
||||
|
||||
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
|
||||
Ok(host.project(window)?.lsp.servers())
|
||||
}
|
||||
@@ -1856,6 +1865,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_type_hierarchy", |c| async move {
|
||||
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
});
|
||||
d.add("lsp_prepare_type_hierarchy", |c| async move {
|
||||
lsp_prepare_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1458,7 +1493,7 @@ fn content_root_watcher(
|
||||
) -> Option<Watcher> {
|
||||
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
|
||||
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
|
||||
let root = dir.to_path_buf();
|
||||
let (root, events) = (dir.to_path_buf(), sink.clone());
|
||||
Watcher::start(
|
||||
dir,
|
||||
excludes.to_vec(),
|
||||
@@ -1480,6 +1515,11 @@ fn content_root_watcher(
|
||||
}
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
// A member outside the project folder: its repository changes (commit, checkout,
|
||||
// fetch, pull) reach the UI only through this watcher.
|
||||
if batch.vcs.iter().any(|p| vcs_path_changed(p)) {
|
||||
events.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
|
||||
|
||||
@@ -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
|
||||
@@ -28,6 +28,7 @@ pub use syntax::{
|
||||
VueComponentImport, VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility,
|
||||
set_external_syntax, shift_pos, shift_range,
|
||||
};
|
||||
pub use syntax::{MethodBody, PythonClassFacts, PythonFacts, PythonMethodFacts};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -43,7 +43,7 @@ fn extract(tree: &Tree, text: &str) -> Vec<ClassBases> {
|
||||
// Selection-range start of the class: its name identifier (`class Foo`
|
||||
// -> `Foo`), matching `outline.rs` and the gutter marker candidates.
|
||||
let range = idx.range(name_node.start_byte(), name_node.end_byte());
|
||||
let bases = node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default();
|
||||
let bases = class_base_names(node, text);
|
||||
out.push(ClassBases { name, line: range.start.line, col: range.start.col, bases });
|
||||
});
|
||||
out
|
||||
@@ -67,6 +67,11 @@ fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bare base names a `class_definition` node writes (keyword arguments left out).
|
||||
pub(super) fn class_base_names(node: Node, text: &str) -> Vec<String> {
|
||||
node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn base_names(args: Node, text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = args.walk();
|
||||
|
||||
@@ -10,6 +10,7 @@ mod inheritance;
|
||||
pub(crate) mod lines;
|
||||
mod literals;
|
||||
mod outline;
|
||||
mod python_facts;
|
||||
mod rst;
|
||||
mod selection;
|
||||
mod sfc;
|
||||
@@ -27,6 +28,7 @@ pub use inheritance::ClassBases;
|
||||
pub use lines::{shift_pos, shift_range};
|
||||
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
|
||||
pub use outline::merge_visibility;
|
||||
pub use python_facts::{MethodBody, PythonClassFacts, PythonFacts, PythonMethodFacts};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Python class facts the inheritance inspections need: base names as written, parameter
|
||||
//! lists, decorator semantics and what a method body does. They come from the syntax tree in
|
||||
//! one pass, so the frontend reasons about the inheritance graph without parsing source text.
|
||||
//!
|
||||
//! Deliberately small: a fact exists here only when a check can decide it without guessing.
|
||||
|
||||
use super::lines::LineIndex;
|
||||
use crate::{LanguageId, Syntax};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
/// One Python module's classes, in source order.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PythonFacts {
|
||||
pub classes: Vec<PythonClassFacts>,
|
||||
}
|
||||
|
||||
/// A class definition and the methods it declares.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PythonClassFacts {
|
||||
pub name: String,
|
||||
/// The class name identifier (selection range).
|
||||
pub selection: crate::Range,
|
||||
/// Base names as written, a qualified name reduced to its last segment.
|
||||
pub bases: Vec<String>,
|
||||
pub methods: Vec<PythonMethodFacts>,
|
||||
}
|
||||
|
||||
/// A method definition the inspections can reason about.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PythonMethodFacts {
|
||||
pub name: String,
|
||||
/// The method name identifier (selection range).
|
||||
pub selection: crate::Range,
|
||||
/// Parameter names in order; `None` for a form whose forwarding cannot be compared
|
||||
/// (`*args`, `**kwargs`, keyword-only, positional-only).
|
||||
pub params: Option<Vec<String>>,
|
||||
/// Decorated with `abstractmethod` (`abc.abstractmethod` included).
|
||||
pub abstract_method: bool,
|
||||
/// Decorated with `override` from `typing` / `typing_extensions`.
|
||||
pub override_decorated: bool,
|
||||
pub body: MethodBody,
|
||||
}
|
||||
|
||||
/// What a method body does, for the shapes a check can decide without guessing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum MethodBody {
|
||||
/// `pass`, `...`, a docstring, or nothing at all.
|
||||
Empty,
|
||||
/// Exactly one `super().name(args)` call, optionally `return`ed.
|
||||
SuperCall { returns: bool, target: String, args: Vec<String> },
|
||||
/// Anything else.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Syntax {
|
||||
/// The classes of a Python `text` with the facts the inheritance checks need.
|
||||
/// Every other language returns an empty result.
|
||||
pub fn python_facts(&self, lang: &LanguageId, text: &str) -> PythonFacts {
|
||||
self.analyze(lang, text, |g, tree, text| (g.spec.name == "python").then(|| extract(tree, text)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(tree: &Tree, text: &str) -> PythonFacts {
|
||||
let idx = LineIndex::new(text);
|
||||
let imports = ImportBindings::collect(tree, text);
|
||||
let mut classes = Vec::new();
|
||||
let mut cursor = tree.walk();
|
||||
visit(&mut cursor, &mut |node| {
|
||||
if node.kind() != "class_definition" {
|
||||
return;
|
||||
}
|
||||
let Some(name_node) = node.child_by_field_name("name") else { return };
|
||||
let name = text_of(name_node, text);
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
classes.push(PythonClassFacts {
|
||||
name,
|
||||
selection: idx.range(name_node.start_byte(), name_node.end_byte()),
|
||||
bases: super::inheritance::class_base_names(node, text),
|
||||
methods: methods_of(node, text, &idx, &imports),
|
||||
});
|
||||
});
|
||||
PythonFacts { classes }
|
||||
}
|
||||
|
||||
/// The methods declared directly in `class`.
|
||||
fn methods_of(class: Node, text: &str, idx: &LineIndex, imports: &ImportBindings) -> Vec<PythonMethodFacts> {
|
||||
let Some(body) = class.child_by_field_name("body") else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = body.walk();
|
||||
for member in body.named_children(&mut cursor) {
|
||||
// A decorated method is a `decorated_definition` wrapping the `function_definition`.
|
||||
let method = match member.kind() {
|
||||
"function_definition" => member,
|
||||
"decorated_definition" => match member.child_by_field_name("definition") {
|
||||
Some(inner) if inner.kind() == "function_definition" => inner,
|
||||
_ => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
let Some(name_node) = method.child_by_field_name("name") else { continue };
|
||||
let name = text_of(name_node, text);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let decorators = decorators_of(method, text);
|
||||
out.push(PythonMethodFacts {
|
||||
name,
|
||||
selection: idx.range(name_node.start_byte(), name_node.end_byte()),
|
||||
params: parameters_of(method, text),
|
||||
abstract_method: decorators.iter().any(|d| last_segment(d) == "abstractmethod"),
|
||||
override_decorated: decorators.iter().any(|d| imports.is_typing_override(d)),
|
||||
body: body_of(method, text),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parameter names of a `function_definition`, or `None` when the signature contains a
|
||||
/// form whose forwarding cannot be compared without guessing.
|
||||
fn parameters_of(function: Node, text: &str) -> Option<Vec<String>> {
|
||||
let params = function.child_by_field_name("parameters")?;
|
||||
let mut names = Vec::new();
|
||||
let mut cursor = params.walk();
|
||||
for param in params.named_children(&mut cursor) {
|
||||
let name = match param.kind() {
|
||||
"identifier" => text_of(param, text),
|
||||
"typed_parameter" | "default_parameter" | "typed_default_parameter" => {
|
||||
let named = param
|
||||
.child_by_field_name("name")
|
||||
.or_else(|| param.child_by_field_name("pattern"))
|
||||
.or_else(|| param.named_child(0))?;
|
||||
if named.kind() != "identifier" {
|
||||
return None;
|
||||
}
|
||||
text_of(named, text)
|
||||
}
|
||||
// `*args`, `**kwargs`, `/`, `*`: the call form cannot be compared textually.
|
||||
_ => return None,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// A `function_definition`'s body, classified into the shapes a check can decide.
|
||||
fn body_of(function: Node, text: &str) -> MethodBody {
|
||||
let Some(body) = function.child_by_field_name("body") else { return MethodBody::Empty };
|
||||
// A docstring is not a statement the checks care about.
|
||||
let statements: Vec<Node> = statements_of(body).into_iter().filter(|s| !is_string(*s)).collect();
|
||||
if statements.is_empty() || statements.iter().all(|s| is_noop(*s)) {
|
||||
return MethodBody::Empty;
|
||||
}
|
||||
let [only] = statements.as_slice() else { return MethodBody::Other };
|
||||
let call = match only.kind() {
|
||||
"expression_statement" => (only.named_child(0), false),
|
||||
"return_statement" => (only.named_child(0), true),
|
||||
_ => (None, false),
|
||||
};
|
||||
match call {
|
||||
(Some(call), returns) if is_super_call(call, text) => {
|
||||
let target = call
|
||||
.child_by_field_name("function")
|
||||
.and_then(|f| f.child_by_field_name("attribute"))
|
||||
.map(|a| text_of(a, text))
|
||||
.unwrap_or_default();
|
||||
let args = call
|
||||
.child_by_field_name("arguments")
|
||||
.map(|args| {
|
||||
let mut cursor = args.walk();
|
||||
args.named_children(&mut cursor).map(|a| text_of(a, text)).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
MethodBody::SuperCall { returns, target, args }
|
||||
}
|
||||
_ => MethodBody::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `node` is a `super().name(...)` call.
|
||||
fn is_super_call(node: Node, text: &str) -> bool {
|
||||
if node.kind() != "call" {
|
||||
return false;
|
||||
}
|
||||
let Some(function) = node.child_by_field_name("function") else { return false };
|
||||
if function.kind() != "attribute" {
|
||||
return false;
|
||||
}
|
||||
function.child_by_field_name("object").is_some_and(|object| {
|
||||
object.kind() == "call"
|
||||
&& object
|
||||
.child_by_field_name("function")
|
||||
.is_some_and(|f| f.kind() == "identifier" && text_of(f, text) == "super")
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `statement` is `pass` or a bare `...`.
|
||||
fn is_noop(statement: Node) -> bool {
|
||||
match statement.kind() {
|
||||
"pass_statement" => true,
|
||||
"expression_statement" => statement.named_child(0).is_some_and(|child| child.kind() == "ellipsis"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A string expression statement: a docstring.
|
||||
fn is_string(statement: Node) -> bool {
|
||||
statement.kind() == "expression_statement" && statement.named_child(0).is_some_and(|child| child.kind() == "string")
|
||||
}
|
||||
|
||||
/// The statements of a `block`, comments excluded.
|
||||
fn statements_of(block: Node) -> Vec<Node> {
|
||||
let mut cursor = block.walk();
|
||||
block.named_children(&mut cursor).filter(|child| child.kind() != "comment").collect()
|
||||
}
|
||||
|
||||
/// The decorators written on a definition, without the `@` or the call arguments.
|
||||
fn decorators_of(definition: Node, text: &str) -> Vec<String> {
|
||||
let Some(parent) = definition.parent() else { return Vec::new() };
|
||||
if parent.kind() != "decorated_definition" {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = parent.walk();
|
||||
for decorator in parent.named_children(&mut cursor) {
|
||||
if decorator.kind() != "decorator" {
|
||||
continue;
|
||||
}
|
||||
let Some(inner) = decorator.named_child(0) else { continue };
|
||||
let target =
|
||||
if inner.kind() == "call" { inner.child_by_field_name("function").unwrap_or(inner) } else { inner };
|
||||
let name = text_of(target, text).trim().to_owned();
|
||||
if !name.is_empty() {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The last segment of a dotted name (`typing.override` -> `override`).
|
||||
fn last_segment(name: &str) -> &str {
|
||||
name.rsplit('.').next().unwrap_or(name)
|
||||
}
|
||||
|
||||
fn text_of(node: Node, text: &str) -> String {
|
||||
text.get(node.byte_range()).unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
/// The module-level `from ... import ...` bindings, so `override` can be told apart from a
|
||||
/// same-named decorator of another library.
|
||||
struct ImportBindings {
|
||||
/// (local name, module, exported name)
|
||||
bindings: Vec<(String, String, String)>,
|
||||
}
|
||||
|
||||
impl ImportBindings {
|
||||
fn collect(tree: &Tree, text: &str) -> Self {
|
||||
let mut bindings = Vec::new();
|
||||
let mut cursor = tree.walk();
|
||||
visit(&mut cursor, &mut |node| {
|
||||
if node.kind() != "import_from_statement" {
|
||||
return;
|
||||
}
|
||||
let Some(module) = node.child_by_field_name("module_name") else { return };
|
||||
let module = text_of(module, text);
|
||||
let mut names = node.walk();
|
||||
for name in node.children_by_field_name("name", &mut names) {
|
||||
let (local, exported) = match name.kind() {
|
||||
"aliased_import" => (
|
||||
name.child_by_field_name("alias").map(|n| text_of(n, text)),
|
||||
name.child_by_field_name("name").map(|n| text_of(n, text)),
|
||||
),
|
||||
_ => {
|
||||
let written = text_of(name, text);
|
||||
(Some(written.clone()), Some(written))
|
||||
}
|
||||
};
|
||||
if let (Some(local), Some(exported)) = (local, exported) {
|
||||
bindings.push((local, module.clone(), exported));
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { bindings }
|
||||
}
|
||||
|
||||
/// Whether `decorator` names `override` from `typing` / `typing_extensions`.
|
||||
fn is_typing_override(&self, decorator: &str) -> bool {
|
||||
const MODULES: [&str; 2] = ["typing", "typing_extensions"];
|
||||
let segments: Vec<&str> = decorator.split('.').filter(|s| !s.is_empty()).collect();
|
||||
match segments.as_slice() {
|
||||
// `@typing.override`, or `@t.override` after `import typing as t`.
|
||||
[prefix, "override"] => {
|
||||
MODULES.contains(prefix)
|
||||
|| self
|
||||
.bindings
|
||||
.iter()
|
||||
.find(|(local, _, _)| local == prefix)
|
||||
.is_some_and(|(_, module, _)| MODULES.contains(&module.as_str()))
|
||||
}
|
||||
// `@override`, or `@ov` after `from typing import override as ov`.
|
||||
[local] => match self.bindings.iter().find(|(bound, _, _)| bound == local) {
|
||||
Some((_, module, exported)) => exported == "override" && MODULES.contains(&module.as_str()),
|
||||
// Nothing contradicts a bare `@override`: the usual `from typing import *`.
|
||||
None => *local == "override",
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth-first pre-order traversal of all descendants of `cursor`'s node.
|
||||
fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
|
||||
loop {
|
||||
f(cursor.node());
|
||||
if cursor.goto_first_child() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
if cursor.goto_next_sibling() {
|
||||
break;
|
||||
}
|
||||
if !cursor.goto_parent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static SYNTAX: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
|
||||
|
||||
fn facts(src: &str) -> PythonFacts {
|
||||
SYNTAX.get_or_init(Syntax::new).python_facts(&LanguageId::new("python"), src)
|
||||
}
|
||||
|
||||
fn method<'a>(facts: &'a PythonFacts, class: &str, method: &str) -> &'a PythonMethodFacts {
|
||||
facts
|
||||
.classes
|
||||
.iter()
|
||||
.find(|c| c.name == class)
|
||||
.and_then(|c| c.methods.iter().find(|m| m.name == method))
|
||||
.expect("method not found")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classes_carry_bases_and_methods() {
|
||||
let f = facts(
|
||||
"class Base(ABC):\n pass\n\nclass Child(Base, pkg.Mixin):\n def run(self, x=1):\n pass\n",
|
||||
);
|
||||
assert_eq!(f.classes.len(), 2);
|
||||
assert_eq!(f.classes[0].name, "Base");
|
||||
assert_eq!(f.classes[0].bases, ["ABC"]);
|
||||
assert_eq!(f.classes[1].bases, ["Base", "Mixin"]);
|
||||
assert_eq!(f.classes[1].methods.len(), 1);
|
||||
assert_eq!(f.classes[1].selection.start.line, 3);
|
||||
assert_eq!(f.classes[1].selection.start.col, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_are_names_in_order_and_none_for_splats() {
|
||||
let f = facts(
|
||||
"class C:\n def a(self, x: int, y=2):\n pass\n\n def b(self, *args):\n pass\n\n def c(self, *, x):\n pass\n",
|
||||
);
|
||||
assert_eq!(
|
||||
method(&f, "C", "a").params.as_deref(),
|
||||
Some(["self".to_string(), "x".to_string(), "y".to_string()].as_slice())
|
||||
);
|
||||
assert_eq!(method(&f, "C", "b").params, None);
|
||||
assert_eq!(method(&f, "C", "c").params, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_shapes_are_classified() {
|
||||
let f = facts(
|
||||
"class C:\n def noop(self):\n pass\n\n def dots(self):\n ...\n\n def doc(self):\n \"\"\"text\"\"\"\n\n def forward(self, x):\n return super().run(x)\n\n def call(self):\n super().run()\n\n def other(self):\n return 1\n",
|
||||
);
|
||||
assert_eq!(method(&f, "C", "noop").body, MethodBody::Empty);
|
||||
assert_eq!(method(&f, "C", "dots").body, MethodBody::Empty);
|
||||
assert_eq!(method(&f, "C", "doc").body, MethodBody::Empty);
|
||||
assert_eq!(
|
||||
method(&f, "C", "forward").body,
|
||||
MethodBody::SuperCall { returns: true, target: "run".into(), args: vec!["x".into()] }
|
||||
);
|
||||
assert_eq!(
|
||||
method(&f, "C", "call").body,
|
||||
MethodBody::SuperCall { returns: false, target: "run".into(), args: vec![] }
|
||||
);
|
||||
assert_eq!(method(&f, "C", "other").body, MethodBody::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorators_are_their_names_and_semantics() {
|
||||
let f = facts(
|
||||
"from abc import abstractmethod\nfrom typing import override\n\nclass C:\n @abstractmethod\n def a(self):\n pass\n\n @override\n def b(self):\n pass\n\n @functools.cache\n def c(self):\n pass\n",
|
||||
);
|
||||
assert!(method(&f, "C", "a").abstract_method);
|
||||
assert!(!method(&f, "C", "a").override_decorated);
|
||||
assert!(method(&f, "C", "b").override_decorated);
|
||||
assert!(!method(&f, "C", "c").override_decorated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_of_another_library_is_not_typing_override() {
|
||||
let f = facts("from mylib import override\n\nclass C:\n @override\n def a(self):\n pass\n");
|
||||
assert!(!method(&f, "C", "a").override_decorated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliased_override_and_qualified_forms_are_recognised() {
|
||||
let f = facts(
|
||||
"from typing import override as ov\n\nclass C:\n @ov\n def a(self):\n pass\n\nclass D:\n @typing_extensions.override\n def b(self):\n pass\n",
|
||||
);
|
||||
assert!(method(&f, "C", "a").override_decorated);
|
||||
assert!(method(&f, "D", "b").override_decorated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_classes_and_other_languages() {
|
||||
let f = facts("class Outer:\n class Inner:\n def m(self):\n pass\n");
|
||||
let names: Vec<&str> = f.classes.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["Outer", "Inner"]);
|
||||
let other = SYNTAX.get_or_init(Syntax::new).python_facts(&LanguageId::new("rust"), "class C {}");
|
||||
assert_eq!(other.classes.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
@@ -3097,6 +3146,21 @@ impl LspManager {
|
||||
Ok(CallHierarchy { items: out, incomplete })
|
||||
}
|
||||
|
||||
/// The type at `pos` as the server's type hierarchy names it (with its `detail`, the
|
||||
/// package for Java); `None` when there is no type there.
|
||||
pub async fn prepare_type_hierarchy(&self, doc: DocId, pos: Pos) -> IdeResult<Option<HierarchyItem>> {
|
||||
let (snap, targets) = self.doc_targets(doc, "type hierarchy", |c| c.has("typeHierarchyProvider")).await?;
|
||||
let t = &targets[0];
|
||||
let items: Option<Vec<serde_json::Value>> =
|
||||
self.call(t, "textDocument/prepareTypeHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?;
|
||||
let conv = self.conv(t.enc());
|
||||
Ok(items
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(convert::type_item_from_json)
|
||||
.find_map(|i| convert::type_item(&conv, &i)))
|
||||
}
|
||||
|
||||
pub async fn type_hierarchy(
|
||||
&self,
|
||||
doc: DocId,
|
||||
@@ -3861,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 {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// One row of a process listing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -104,16 +105,21 @@ pub struct MemoryRoot {
|
||||
}
|
||||
|
||||
/// Memory of one row of the breakdown.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessMemory {
|
||||
pub category: MemoryCategory,
|
||||
pub label: String,
|
||||
/// Project, run kind or similar.
|
||||
pub detail: Option<String>,
|
||||
/// The row's top process (the language server, run or terminal shell).
|
||||
pub pid: u32,
|
||||
/// Number of processes counted in this row.
|
||||
pub processes: u32,
|
||||
pub bytes: Option<u64>,
|
||||
/// CPU use since the previous measurement, in percent of one core (Activity
|
||||
/// Monitor's "% CPU"); `None` on the first measurement of every process.
|
||||
pub cpu_percent: Option<f32>,
|
||||
}
|
||||
|
||||
/// Recognises WebView helpers that are our children (WebKitGTK, WebView2).
|
||||
@@ -137,17 +143,21 @@ pub fn attribute(
|
||||
self_pid: u32,
|
||||
roots: &[MemoryRoot],
|
||||
footprint: impl Fn(u32) -> Option<u64>,
|
||||
mut cpu: impl FnMut(u32) -> Option<f32>,
|
||||
) -> Vec<ProcessMemory> {
|
||||
let mut claimed = HashSet::from([self_pid]);
|
||||
let mut out = Vec::new();
|
||||
let mut push = |category, label: String, detail, pids: Vec<u32>| {
|
||||
let sizes: Vec<u64> = pids.iter().filter_map(|&p| footprint(p)).collect();
|
||||
let loads: Vec<f32> = pids.iter().filter_map(|&p| cpu(p)).collect();
|
||||
out.push(ProcessMemory {
|
||||
category,
|
||||
label,
|
||||
detail,
|
||||
pid: pids[0],
|
||||
processes: pids.len() as u32,
|
||||
bytes: (!sizes.is_empty()).then(|| sizes.iter().sum()),
|
||||
cpu_percent: (!loads.is_empty()).then(|| loads.iter().sum()),
|
||||
});
|
||||
};
|
||||
push(MemoryCategory::App, "Backend".into(), None, vec![self_pid]);
|
||||
@@ -180,6 +190,84 @@ pub fn attribute(
|
||||
out
|
||||
}
|
||||
|
||||
/// CPU load per process from the change of its CPU time between measurements.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CpuSampler {
|
||||
last: HashMap<u32, CpuSample>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CpuSample {
|
||||
time: Duration,
|
||||
at: Instant,
|
||||
percent: Option<f32>,
|
||||
}
|
||||
|
||||
/// Shorter gaps (several windows polling at once) keep the previous value
|
||||
/// instead of measuring over a few milliseconds.
|
||||
const MIN_CPU_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
impl CpuSampler {
|
||||
/// Percent of one core used by `pid` since its previous sample.
|
||||
pub fn percent(&mut self, pid: u32) -> Option<f32> {
|
||||
self.record(pid, cpu_time(pid)?, Instant::now())
|
||||
}
|
||||
|
||||
fn record(&mut self, pid: u32, time: Duration, at: Instant) -> Option<f32> {
|
||||
let percent = match self.last.get(&pid) {
|
||||
Some(prev) if at.duration_since(prev.at) < MIN_CPU_INTERVAL => return prev.percent,
|
||||
// A reused pid restarts its CPU time.
|
||||
Some(prev) if time >= prev.time => {
|
||||
Some((time - prev.time).as_secs_f32() / at.duration_since(prev.at).as_secs_f32() * 100.0)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
self.last.insert(pid, CpuSample { time, at, percent });
|
||||
percent
|
||||
}
|
||||
|
||||
/// Forgets processes that are no longer listed.
|
||||
pub fn retain(&mut self, table: &ProcessTable) {
|
||||
if !table.entries.is_empty() {
|
||||
self.last.retain(|pid, _| table.get(*pid).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU time (user + system) `pid` has used so far.
|
||||
pub fn cpu_time(pid: u32) -> Option<Duration> {
|
||||
imp::cpu_time(pid)
|
||||
}
|
||||
|
||||
/// Ends `pid` and every process it started, children first: a polite request
|
||||
/// (`SIGTERM`), then a forced kill after `grace` for whatever is still running.
|
||||
/// Refuses (returns false) unless `pid` descends from `self_pid`.
|
||||
pub fn end_process_tree(self_pid: u32, pid: u32, grace: Duration) -> bool {
|
||||
let table = ProcessTable::snapshot();
|
||||
if pid == self_pid || !table.tree(self_pid).contains(&pid) {
|
||||
return false;
|
||||
}
|
||||
let pids: Vec<u32> = table.tree(pid).into_iter().rev().collect();
|
||||
for &p in &pids {
|
||||
imp::terminate(p, false);
|
||||
}
|
||||
let deadline = Instant::now() + grace;
|
||||
loop {
|
||||
let now = ProcessTable::snapshot();
|
||||
let left: Vec<u32> = pids.iter().copied().filter(|p| now.get(*p).is_some()).collect();
|
||||
if left.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
for p in left {
|
||||
imp::terminate(p, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes the OS attributes to `pid` (see the module docs); `None` when the
|
||||
/// process is gone or not readable.
|
||||
pub fn footprint_bytes(pid: u32) -> Option<u64> {
|
||||
@@ -262,6 +350,15 @@ mod imp {
|
||||
fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: *mut c_void, size: i32) -> i32;
|
||||
fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut c_void) -> i32;
|
||||
fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
|
||||
fn mach_timebase_info(info: *mut MachTimebaseInfo) -> i32;
|
||||
fn kill(pid: i32, signal: i32) -> i32;
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct MachTimebaseInfo {
|
||||
numer: u32,
|
||||
denom: u32,
|
||||
}
|
||||
|
||||
#[link(name = "objc")]
|
||||
@@ -317,6 +414,27 @@ mod imp {
|
||||
(rc == 0).then_some(info[PHYS_FOOTPRINT_WORD])
|
||||
}
|
||||
|
||||
/// `ri_user_time + ri_system_time` (words 2 and 3), in Mach absolute time units.
|
||||
pub fn cpu_time(pid: u32) -> Option<std::time::Duration> {
|
||||
static TIMEBASE: std::sync::OnceLock<(u64, u64)> = std::sync::OnceLock::new();
|
||||
let &(numer, denom) = TIMEBASE.get_or_init(|| {
|
||||
let mut tb = MachTimebaseInfo::default();
|
||||
// SAFETY: `tb` is a writable mach_timebase_info_data_t.
|
||||
let ok = unsafe { mach_timebase_info(&mut tb) } == 0 && tb.denom != 0;
|
||||
if ok { (tb.numer as u64, tb.denom as u64) } else { (1, 1) }
|
||||
});
|
||||
let mut info = [0u64; 64];
|
||||
// SAFETY: `info` (512 bytes) is larger than `rusage_info_v4`.
|
||||
let rc = unsafe { proc_pid_rusage(pid as i32, RUSAGE_INFO_V4, info.as_mut_ptr().cast()) };
|
||||
let ticks = (rc == 0).then(|| info[2].saturating_add(info[3]))?;
|
||||
Some(std::time::Duration::from_nanos((ticks as u128 * numer as u128 / denom as u128) as u64))
|
||||
}
|
||||
|
||||
pub fn terminate(pid: u32, force: bool) {
|
||||
// SAFETY: plain syscall.
|
||||
unsafe { kill(pid as i32, if force { 9 } else { 15 }) };
|
||||
}
|
||||
|
||||
/// `responsibility_get_pid_responsible_for_pid` (libsystem SPI used by
|
||||
/// Activity Monitor), looked up at run time so a missing symbol only
|
||||
/// disables the fallback.
|
||||
@@ -406,6 +524,39 @@ mod imp {
|
||||
None
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn sysconf(name: i32) -> i64;
|
||||
fn kill(pid: i32, signal: i32) -> i32;
|
||||
}
|
||||
const SC_CLK_TCK: i32 = 2;
|
||||
|
||||
/// `utime + stime` of `/proc/<pid>/stat` (fields 14 and 15, in clock ticks).
|
||||
fn stat_ticks(text: &str) -> Option<u64> {
|
||||
let mut rest = text.get(text.rfind(')')? + 1..)?.split_whitespace().skip(11);
|
||||
Some(rest.next()?.parse::<u64>().ok()? + rest.next()?.parse::<u64>().ok()?)
|
||||
}
|
||||
|
||||
pub fn cpu_time(pid: u32) -> Option<std::time::Duration> {
|
||||
static HZ: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||
// SAFETY: plain libc call.
|
||||
let hz =
|
||||
*HZ.get_or_init(|| u64::try_from(unsafe { sysconf(SC_CLK_TCK) }).ok().filter(|&h| h > 0).unwrap_or(100));
|
||||
let ticks = stat_ticks(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)?;
|
||||
Some(std::time::Duration::from_nanos(ticks * 1_000_000_000 / hz))
|
||||
}
|
||||
|
||||
pub fn terminate(pid: u32, force: bool) {
|
||||
// SAFETY: plain syscall.
|
||||
unsafe { kill(pid as i32, if force { 9 } else { 15 }) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn reads_cpu_ticks_from_stat() {
|
||||
let text = "7 (a (b) c) S 3 7 7 0 -1 4194560 100 0 0 0 250 50 0 0 20 0 1 0";
|
||||
assert_eq!(stat_ticks(text), Some(300));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn parses_stat_with_parens_in_name() {
|
||||
@@ -463,8 +614,18 @@ mod imp {
|
||||
fn CloseHandle(handle: *mut c_void) -> i32;
|
||||
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void;
|
||||
fn K32GetProcessMemoryInfo(process: *mut c_void, counters: *mut c_void, cb: u32) -> i32;
|
||||
fn GetProcessTimes(
|
||||
process: *mut c_void,
|
||||
creation: *mut u64,
|
||||
exit: *mut u64,
|
||||
kernel: *mut u64,
|
||||
user: *mut u64,
|
||||
) -> i32;
|
||||
fn TerminateProcess(process: *mut c_void, code: u32) -> i32;
|
||||
}
|
||||
|
||||
const PROCESS_TERMINATE: u32 = 0x1;
|
||||
|
||||
pub fn list_processes() -> Vec<ProcEntry> {
|
||||
// SAFETY: plain Win32 call; the handle is closed below.
|
||||
let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
@@ -507,6 +668,34 @@ mod imp {
|
||||
(ok != 0).then_some(c.PrivateUsage as u64)
|
||||
}
|
||||
|
||||
/// Kernel + user time from `GetProcessTimes` (100 ns units).
|
||||
pub fn cpu_time(pid: u32) -> Option<std::time::Duration> {
|
||||
// SAFETY: plain Win32 call; the handle is closed below.
|
||||
let h = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if h.is_null() {
|
||||
return None;
|
||||
}
|
||||
let (mut creation, mut exit, mut kernel, mut user) = (0u64, 0u64, 0u64, 0u64);
|
||||
// SAFETY: `h` is a process handle; the FILETIMEs are written as u64s.
|
||||
let ok = unsafe { GetProcessTimes(h, &mut creation, &mut exit, &mut kernel, &mut user) };
|
||||
// SAFETY: `h` came from OpenProcess.
|
||||
unsafe { CloseHandle(h) };
|
||||
(ok != 0).then(|| std::time::Duration::from_nanos(kernel.saturating_add(user).saturating_mul(100)))
|
||||
}
|
||||
|
||||
/// Windows has no polite request for a console process; both steps terminate it.
|
||||
pub fn terminate(pid: u32, _force: bool) {
|
||||
// SAFETY: plain Win32 call; the handle is closed below.
|
||||
let h = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `h` is a process handle opened with PROCESS_TERMINATE.
|
||||
unsafe { TerminateProcess(h, 1) };
|
||||
// SAFETY: `h` came from OpenProcess.
|
||||
unsafe { CloseHandle(h) };
|
||||
}
|
||||
|
||||
pub fn responsible_pid(_pid: u32) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
@@ -522,6 +711,10 @@ mod imp {
|
||||
pub fn footprint_bytes(_pid: u32) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
pub fn cpu_time(_pid: u32) -> Option<std::time::Duration> {
|
||||
None
|
||||
}
|
||||
pub fn terminate(_pid: u32, _force: bool) {}
|
||||
pub fn responsible_pid(_pid: u32) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
@@ -589,7 +782,7 @@ mod tests {
|
||||
// Already counted under rust-analyzer: no row.
|
||||
root(301, MemoryCategory::Process, "dup", true),
|
||||
];
|
||||
let rows = attribute(&t, 100, &roots, mb);
|
||||
let rows = attribute(&t, 100, &roots, mb, |pid| (pid == 300 || pid == 301).then_some(25.0));
|
||||
let summary: Vec<(MemoryCategory, &str, u32, Option<u64>)> =
|
||||
rows.iter().map(|r| (r.category, r.label.as_str(), r.processes, r.bytes)).collect();
|
||||
assert_eq!(
|
||||
@@ -606,17 +799,56 @@ mod tests {
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[2].detail.as_deref(), Some("idea"));
|
||||
let pids: Vec<u32> = rows.iter().map(|r| r.pid).collect();
|
||||
assert_eq!(pids, vec![100, 700, 200, 300, 400, 500, 600]);
|
||||
assert_eq!(rows[3].cpu_percent, Some(50.0));
|
||||
assert_eq!(rows[2].cpu_percent, None);
|
||||
assert!(!rows.iter().any(|r| r.label == "Safari"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_rows_have_no_bytes() {
|
||||
let t = table();
|
||||
let rows = attribute(&t, 100, &[root(401, MemoryCategory::Process, "cargo", false)], mb);
|
||||
let rows = attribute(&t, 100, &[root(401, MemoryCategory::Process, "cargo", false)], mb, |_| None);
|
||||
let cargo = rows.iter().find(|r| r.label == "cargo").unwrap();
|
||||
assert_eq!((cargo.processes, cargo.bytes), (1, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_percent_comes_from_the_change_in_cpu_time() {
|
||||
let mut s = CpuSampler::default();
|
||||
let t0 = Instant::now();
|
||||
let ms = Duration::from_millis;
|
||||
assert_eq!(s.record(7, ms(1000), t0), None, "first sample has nothing to compare with");
|
||||
assert_eq!(s.record(7, ms(1500), t0 + ms(1000)), Some(50.0));
|
||||
assert_eq!(s.record(7, ms(1900), t0 + ms(1100)), Some(50.0), "too soon: previous value");
|
||||
assert_eq!(s.record(7, ms(3500), t0 + ms(2000)), Some(200.0), "two busy cores");
|
||||
assert_eq!(s.record(7, ms(10), t0 + ms(3000)), None, "pid reused by a new process");
|
||||
s.retain(&ProcessTable::from_entries([e(8, 1, "other")]));
|
||||
assert_eq!(s.record(7, ms(500), t0 + ms(4000)), None, "forgotten once unlisted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ends_only_descendants() {
|
||||
let me = std::process::id();
|
||||
assert!(!end_process_tree(me, me, Duration::ZERO));
|
||||
assert!(!end_process_tree(me, 1, Duration::ZERO));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ends_a_child_and_its_children() {
|
||||
let mut child = std::process::Command::new("sh").args(["-c", "sleep 30 & wait"]).spawn().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while ProcessTable::snapshot().children(child.id()).is_empty() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let sleeper = ProcessTable::snapshot().children(child.id())[0];
|
||||
assert!(end_process_tree(std::process::id(), child.id(), Duration::from_secs(2)));
|
||||
assert!(child.wait().is_ok());
|
||||
assert!(ProcessTable::snapshot().get(sleeper).is_none(), "grandchild ended too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_this_process() {
|
||||
if cfg!(any(target_os = "macos", target_os = "linux", windows)) {
|
||||
@@ -625,6 +857,13 @@ mod tests {
|
||||
assert!(t.get(me).is_some(), "own pid listed");
|
||||
let bytes = footprint_bytes(me).expect("own footprint");
|
||||
assert!(bytes > 1 << 16 && bytes < 1 << 40, "{bytes}");
|
||||
let before = cpu_time(me).expect("own CPU time");
|
||||
let spin = Instant::now();
|
||||
while spin.elapsed() < Duration::from_millis(50) {
|
||||
std::hint::black_box(spin.elapsed());
|
||||
}
|
||||
let used = cpu_time(me).unwrap() - before;
|
||||
assert!(used > Duration::from_millis(10) && used < Duration::from_secs(5), "{used:?}");
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(imp::responsible_pid(std::process::id()).is_some(), "responsibility SPI resolved");
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
# Per-user IDE state
|
||||
/shelf/
|
||||
/changelists.json
|
||||
@@ -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
|
||||
Бінарний файл не відображається.
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача