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

..
Автор SHA1 Повідомлення Дата
l0ng-ai 7c9ff885dd fix(debugger): let a Python program read input while it is debugged
debugpy launched the program with console "internalConsole", which gives it no
standard input: input() raised EOFError right after printing its prompt. The
program now runs on the pseudo-terminal the debugger already provides for
runInTerminal, so what is typed in the debug console reaches it, as in a Run.
Its output reaches the console from that terminal, so debugpy no longer
captures it a second time (redirectOutput).
2026-11-14 21:33:53 +08:00
228 змінених файлів з 716 додано та 11045 видалено

No files matched your search

Створено
-3
Переглянути файл
@@ -4146,7 +4146,6 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
name = "ide-agent"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
"encoding_rs",
"ide-core",
@@ -4269,7 +4268,6 @@ dependencies = [
name = "ide-dap"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"futures",
"ide-core",
"ide-heap",
@@ -4288,7 +4286,6 @@ dependencies = [
"specta",
"tempfile",
"tokio",
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
-4
Переглянути файл
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
encoding_rs.workspace = true
blake3.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
@@ -25,6 +24,3 @@ dunce.workspace = true
uuid.workspace = true
tempfile.workspace = true
which = "8"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+27 -35
Переглянути файл
@@ -3,7 +3,6 @@
//! `initialize` alone).
use super::{Conn, Dialect};
use crate::catalogs::Discovered;
use crate::config;
use crate::model::AgentConfigOption;
use crate::provider::ProviderSpec;
@@ -16,7 +15,7 @@ use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// 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> {
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
let root = shared.paths.project_root.clone();
let launch = spec.clone();
let launch = tokio::task::spawn_blocking({
@@ -26,12 +25,12 @@ pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Dis
.await
.map_err(|e| e.to_string())?;
let cwd = dunce::canonicalize(&root).unwrap_or(root);
let updates = Arc::new(DiscoveryUpdates { provider: spec.id.clone(), commands: Mutex::new(None) });
let client = Dialect::wrap(updates.clone());
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(|_| {});
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
let lines = stderr.clone();
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
let _stop = crate::catalogs::KillPeer(peer.clone());
let conn = Conn::new(peer.clone(), client);
let work = async {
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
@@ -52,41 +51,34 @@ pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Dis
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
};
let commands = updates
.commands
.lock()
.clone()
.or_else(|| new.get("availableCommands").map(|_| config::commands_of(&new, &launch.id)));
Ok::<_, String>(Discovered { config: options, commands, levels: levels.into_iter().collect() })
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
tracing::info!(
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
launch.id,
options.len(),
option_ids,
levels.len()
);
shared.remember_discovered(&launch.id, &options, &[], &levels);
Ok::<(), String>(())
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
match result {
Ok(result) => result,
Err(_) => Err("the agent did not answer in time".into()),
}
}
#[derive(Default)]
struct DiscoveryUpdates {
provider: String,
commands: Mutex<Option<Vec<crate::AgentCommand>>>,
}
impl ide_jsonrpc::Handler for DiscoveryUpdates {
fn on_notification(&self, method: &str, params: Value) {
if method == "session/update"
&& let Some(update) = params.get("update")
&& update["sessionUpdate"] == "available_commands_update"
{
// Commands are protocol data, not stderr (which may contain credentials).
*self.commands.lock() = Some(config::commands_of(update, &self.provider));
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(match stderr.lock().last() {
Some(line) => format!("{e}: {line}"),
None => e,
}),
Err(_) => {
let tail = stderr.lock().join("\n");
if tail.is_empty() {
Err("the agent did not answer in time".into())
} else {
Err(format!("the agent did not answer in time:\n{tail}"))
}
}
}
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unsupported discovery request {method}")))
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
-1
Переглянути файл
@@ -108,7 +108,6 @@ fn manager(dir: &Path, agent: Arc<FakeV2Agent>) -> AgentManager {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
-1
Переглянути файл
@@ -155,7 +155,6 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
-427
Переглянути файл
@@ -1,427 +0,0 @@
//! Model availability for new tasks. Sessions own selections; discovery owns catalogs.
use crate::{
AgentCommand, AgentConfigOption, AgentEvent, AgentModelCatalog, Known, Shared, acp, config, native, provider,
};
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use std::time::Duration;
use tokio::sync::watch;
use tokio::time::Instant;
const FRESH_FOR: Duration = Duration::from_secs(15 * 60);
const COOLDOWN: Duration = Duration::from_secs(30);
type Store = Arc<Mutex<HashMap<String, Known>>>;
type Key = (PathBuf, PathBuf, String);
/// Backend-wide single-flight model lookups, scoped to project and effective launch configuration.
#[derive(Default)]
pub struct AgentCatalogs {
state: Mutex<State>,
#[cfg(test)]
clock_ms: std::sync::atomic::AtomicU64,
}
#[derive(Default)]
struct State {
stores: HashMap<PathBuf, Store>,
entries: HashMap<Key, Entry>,
subscribers: Vec<Weak<Shared>>,
generation: u64,
}
struct Entry {
// Launch credentials remain only in memory, never serialized or logged.
context: LaunchContext,
generation: u64,
completed: Option<Instant>,
successful: Option<Instant>,
retry_at_ms: Option<f64>,
error: Option<String>,
flight: Option<watch::Receiver<bool>>,
abort: Option<tokio::task::AbortHandle>,
catalog: Option<CatalogData>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CatalogData {
pub config: Vec<AgentConfigOption>,
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
pub refreshed_at_ms: Option<f64>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SavedCatalog {
/// An opaque cryptographic digest, never raw launch arguments or credentials.
fingerprint: String,
#[serde(flatten)]
catalog: CatalogData,
}
#[derive(PartialEq)]
struct LaunchContext {
spec: provider::ProviderSpec,
executable: PathBuf,
modified: Option<std::time::SystemTime>,
len: Option<u64>,
}
impl LaunchContext {
fn new(spec: provider::ProviderSpec, root: &std::path::Path) -> Self {
let resolved = spec.clone().resolve_for_launch(root);
let executable = dunce::canonicalize(&resolved.command).unwrap_or_else(|_| PathBuf::from(&resolved.command));
let metadata = std::fs::metadata(&executable).ok();
Self {
spec,
executable,
modified: metadata.as_ref().and_then(|m| m.modified().ok()),
len: metadata.as_ref().map(std::fs::Metadata::len),
}
}
fn fingerprint(&self) -> String {
let mut hash = blake3::Hasher::new();
fn field(hash: &mut blake3::Hasher, value: &[u8]) {
hash.update(&(value.len() as u64).to_le_bytes());
hash.update(value);
}
field(&mut hash, b"openidea-model-catalog-v1");
field(
&mut hash,
match self.spec.driver {
provider::Driver::Acp => b"acp",
provider::Driver::Codex => b"codex",
provider::Driver::Claude => b"claude",
},
);
field(&mut hash, self.spec.command.as_bytes());
hash.update(&(self.spec.args.len() as u64).to_le_bytes());
for arg in &self.spec.args {
field(&mut hash, arg.as_bytes());
}
hash.update(&(self.spec.env.len() as u64).to_le_bytes());
for (name, value) in &self.spec.env {
field(&mut hash, name.as_bytes());
field(&mut hash, value.as_bytes());
}
field(&mut hash, self.executable.to_string_lossy().as_bytes());
if let Some(modified) = self.modified.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) {
hash.update(&modified.as_nanos().to_le_bytes());
}
if let Some(len) = self.len {
hash.update(&len.to_le_bytes());
}
hash.finalize().to_hex().to_string()
}
}
pub(crate) struct Discovered {
pub config: Vec<AgentConfigOption>,
pub commands: Option<Vec<AgentCommand>>,
pub levels: BTreeMap<String, Vec<AgentConfigOption>>,
}
pub(crate) struct KillPeer(pub ide_jsonrpc::Peer);
impl Drop for KillPeer {
fn drop(&mut self) {
self.0.kill();
}
}
impl AgentCatalogs {
fn now(&self) -> Instant {
let now = Instant::now();
#[cfg(test)]
let now = now + Duration::from_millis(self.clock_ms.load(std::sync::atomic::Ordering::Relaxed));
now
}
fn epoch_ms(&self) -> f64 {
let now = crate::now_ms();
#[cfg(test)]
let now = now + self.clock_ms.load(std::sync::atomic::Ordering::Relaxed) as f64;
now
}
pub(crate) fn store(&self, path: &std::path::Path) -> Store {
self.state
.lock()
.stores
.entry(path.to_path_buf())
.or_insert_with(|| {
Arc::new(Mutex::new(
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(),
))
})
.clone()
}
pub(crate) fn subscribe(&self, shared: &Arc<Shared>) {
let mut state = self.state.lock();
state.subscribers.retain(|s| s.strong_count() > 0);
state.subscribers.push(Arc::downgrade(shared));
}
fn key(shared: &Shared, provider: &str) -> Key {
(shared.paths.models_file.clone(), shared.paths.project_root.clone(), provider.to_owned())
}
fn fallback(shared: &Shared, provider: &str) -> CatalogData {
let cache = shared.known.lock();
let Some(known) = cache.get(provider) else { return CatalogData::default() };
known.catalogs.get(&shared.root_key()).map(|saved| saved.catalog.clone()).unwrap_or_else(|| CatalogData {
config: known.config.clone(),
thought_levels: known.thought_levels.clone(),
refreshed_at_ms: None,
})
}
pub(crate) fn cached(&self, shared: &Shared, provider: &str) -> CatalogData {
self.state
.lock()
.entries
.get(&Self::key(shared, provider))
.and_then(|e| e.catalog.clone())
.unwrap_or_else(|| Self::fallback(shared, provider))
}
fn snapshot(&self, shared: &Shared, provider: &str, entry: &Entry) -> AgentModelCatalog {
let catalog = entry.catalog.clone().unwrap_or_else(|| Self::fallback(shared, provider));
AgentModelCatalog {
provider: provider.to_owned(),
config: shared.config_for_new_task(catalog.config, provider),
thought_levels: catalog.thought_levels,
refreshing: entry.flight.is_some(),
refreshed_at_ms: catalog.refreshed_at_ms,
retry_at_ms: entry
.retry_at_ms
.filter(|_| entry.completed.is_some_and(|at| self.now().duration_since(at) < COOLDOWN)),
error: entry.error.clone(),
}
}
/// Start or join a lookup. No queued refresh can outlive the request that asked for it.
pub(crate) fn request(
self: &Arc<Self>,
shared: &Arc<Shared>,
spec: provider::ProviderSpec,
force: bool,
) -> (AgentModelCatalog, Option<watch::Receiver<bool>>) {
let key = Self::key(shared, &spec.id);
let context = LaunchContext::new(spec.clone(), &shared.paths.project_root);
let now = self.now();
let mut state = self.state.lock();
let changed = state.entries.get(&key).is_none_or(|e| e.context != context);
if changed {
if let Some(abort) = state.entries.get_mut(&key).and_then(|entry| entry.abort.take()) {
abort.abort();
}
state.generation += 1;
let generation = state.generation;
let saved =
shared.known.lock().get(&spec.id).and_then(|known| known.catalogs.get(&shared.root_key())).cloned();
let matching = saved.as_ref().is_some_and(|saved| saved.fingerprint == context.fingerprint());
let age = saved
.as_ref()
.filter(|_| matching)
.and_then(|saved| saved.catalog.refreshed_at_ms)
.map(|at| self.epoch_ms() - at)
.filter(|age| *age >= 0.0 && *age < FRESH_FOR.as_millis() as f64)
.map(|age| Duration::from_secs_f64(age / 1000.0));
let successful = age.and_then(|age| now.checked_sub(age));
let completed = age.filter(|age| *age < COOLDOWN).and_then(|age| now.checked_sub(age));
let retry_at_ms = completed
.and_then(|_| saved.as_ref().and_then(|s| s.catalog.refreshed_at_ms))
.map(|at| at + COOLDOWN.as_millis() as f64);
let catalog = saved.map(|saved| {
let mut catalog = saved.catalog;
if !matching {
catalog.refreshed_at_ms = None;
}
catalog
});
state.entries.insert(
key.clone(),
Entry {
context,
generation,
completed,
successful,
retry_at_ms,
catalog,
error: None,
flight: None,
abort: None,
},
);
}
let entry = state.entries.get_mut(&key).expect("catalog entry");
let cooling = entry.completed.is_some_and(|at| now.duration_since(at) < COOLDOWN);
let fresh = entry.successful.is_some_and(|at| now.duration_since(at) < FRESH_FOR);
let mut start = None;
if entry.flight.is_none() && !cooling && (force || !fresh) {
let (done, ready) = watch::channel(false);
entry.flight = Some(ready);
entry.error = None;
entry.retry_at_ms = None;
start = Some((entry.generation, done));
}
let snapshot = self.snapshot(shared, &spec.id, entry);
let flight = entry.flight.clone();
drop(state);
if let Some((generation, done)) = start {
self.broadcast(shared, &snapshot);
let coordinator = self.clone();
let shared = shared.clone();
let flight_key = key.clone();
let task = shared.runtime.clone().spawn(async move {
let result = discover(&shared, spec).await;
coordinator.complete(&shared, flight_key, generation, result);
let _ = done.send(true);
});
// A subscriber can re-enter and change configuration during the start event.
let mut state = self.state.lock();
if let Some(entry) = state.entries.get_mut(&key).filter(|entry| entry.generation == generation) {
if entry.flight.is_some() {
entry.abort = Some(task.abort_handle());
}
} else {
task.abort();
}
}
(snapshot, flight)
}
fn complete(&self, shared: &Shared, key: Key, generation: u64, result: Result<Discovered, String>) {
let current = provider::load(shared.paths.custom_agents_dir.as_deref(), shared.env_secrets())
.into_iter()
.find(|spec| spec.id == key.2)
.map(|spec| LaunchContext::new(spec, &shared.paths.project_root));
let mut state = self.state.lock();
let Some(entry) = state.entries.get_mut(&key).filter(|e| e.generation == generation) else { return };
// Configuration may have changed without another refresh request arriving.
if current.as_ref() != Some(&entry.context) {
entry.flight = None;
entry.abort = None;
entry.completed = None;
entry.retry_at_ms = None;
entry.error = None;
let snapshot = self.snapshot(shared, &key.2, entry);
state.entries.remove(&key);
drop(state);
self.broadcast(shared, &snapshot);
return;
}
let now = self.now();
entry.completed = Some(now);
entry.retry_at_ms = Some(self.epoch_ms() + COOLDOWN.as_millis() as f64);
entry.flight = None;
entry.abort = None;
let success = match result {
Ok(found) => {
let mut cache = shared.known.lock();
let known = cache.entry(key.2.clone()).or_default();
let catalog = CatalogData {
config: found.config,
thought_levels: found.levels,
refreshed_at_ms: Some(self.epoch_ms()),
};
known.config = catalog.config.clone();
known.thought_levels = catalog.thought_levels.clone();
if let Some(commands) = found.commands {
known.commands.insert(shared.root_key(), commands);
}
known.authoritative = true;
known.refreshed_at_ms = catalog.refreshed_at_ms;
known.catalogs.insert(
shared.root_key(),
SavedCatalog { fingerprint: entry.context.fingerprint(), catalog: catalog.clone() },
);
entry.catalog = Some(catalog);
shared.persist_known(&cache);
entry.successful = Some(now);
entry.error = None;
true
}
Err(error) => {
entry.error = Some(error);
false
}
};
let snapshot = self.snapshot(shared, &key.2, entry);
drop(state);
self.broadcast(shared, &snapshot);
if success {
for subscriber in self.subscribers(shared) {
subscriber.emit(AgentEvent::Providers);
}
}
}
fn subscribers(&self, shared: &Shared) -> Vec<Arc<Shared>> {
self.state
.lock()
.subscribers
.iter()
.filter_map(Weak::upgrade)
.filter(|s| {
s.paths.models_file == shared.paths.models_file && s.paths.project_root == shared.paths.project_root
})
.collect()
}
fn broadcast(&self, shared: &Shared, catalog: &AgentModelCatalog) {
for subscriber in self.subscribers(shared) {
subscriber.emit(AgentEvent::ModelCatalog { catalog: catalog.clone() });
}
}
}
impl crate::AgentManager {
/// Return cached choices immediately and schedule discovery when eligible.
pub fn refresh_models(&self, provider: &str, force: bool) -> IdeResult<AgentModelCatalog> {
// Settings and environment secrets can change while this window is open.
*self.shared.providers.write() =
provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
Ok(self.shared.paths.catalogs.request(&self.shared, spec, force).0)
}
}
async fn discover(shared: &Arc<Shared>, spec: provider::ProviderSpec) -> Result<Discovered, String> {
match spec.driver {
provider::Driver::Acp => acp::discover::run(shared, &spec).await,
provider::Driver::Codex => native::codex::discover(spec, shared.paths.project_root.clone()).await,
provider::Driver::Claude => {
let found = native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await?;
Ok(Discovered {
commands: Some(config::commands_of(
&serde_json::json!({ "availableCommands": found.commands }),
&spec.id,
)),
config: config::options_of(&serde_json::json!({ "configOptions": found.options })),
levels: found
.efforts
.into_iter()
.map(|(model, option)| {
let levels = option
.map(|o| config::options_of(&serde_json::json!({ "configOptions": [o] })))
.unwrap_or_default();
(model, levels)
})
.collect(),
})
}
}
}
#[cfg(test)]
mod tests;
-501
Переглянути файл
@@ -1,501 +0,0 @@
use super::*;
use crate::{AgentManager, AgentPaths, Connector};
use ide_jsonrpc::{Framing, Peer};
use serde_json::{Value, json};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot};
const PROVIDER: &str = "catalog-test";
#[derive(Debug)]
struct Pending {
params: Value,
reply: oneshot::Sender<Result<Value, String>>,
}
struct Harness {
connector: Connector,
pending: mpsc::UnboundedReceiver<Pending>,
launches: Arc<AtomicUsize>,
live: Arc<AtomicUsize>,
}
struct Live(Arc<AtomicUsize>);
impl Drop for Live {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
impl Harness {
fn new() -> Self {
let (pending_tx, pending) = mpsc::unbounded_channel();
let launches = Arc::new(AtomicUsize::new(0));
let live = Arc::new(AtomicUsize::new(0));
let connector: Connector = Arc::new({
let launches = launches.clone();
let live = live.clone();
move |_, _, client, _| {
launches.fetch_add(1, Ordering::SeqCst);
live.fetch_add(1, Ordering::SeqCst);
let guard = Live(live.clone());
let pending = pending_tx.clone();
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let (read, mut write) = tokio::io::split(server_io);
tokio::spawn(async move {
let _guard = guard;
let mut lines = BufReader::new(read).lines();
while let Some(line) = lines.next_line().await.unwrap() {
let request: Value = serde_json::from_str(&line).unwrap();
let id = &request["id"];
if id.is_null() {
continue;
}
let response = match request["method"].as_str().unwrap() {
"initialize" => json!({ "id": id, "jsonrpc": "2.0", "result": {
"protocolVersion": 1, "agentCapabilities": {}, "authMethods": [] } }),
"session/new" => {
let (reply, result) = oneshot::channel();
pending.send(Pending { params: request["params"].clone(), reply }).unwrap();
let result = tokio::select! {
result = result => result.unwrap(),
_ = lines.next_line() => break,
};
match result {
Ok(result) => json!({ "id": id, "jsonrpc": "2.0", "result": result }),
Err(error) => {
json!({ "id": id, "jsonrpc": "2.0", "error": { "code": -32000, "message": error } })
}
}
}
method => panic!("discovery must not call {method}"),
};
if write.write_all(format!("{response}\n").as_bytes()).await.is_err() {
break;
}
}
});
let (read, write) = tokio::io::split(client_io);
Ok(Peer::connect_framed(read, write, client, Framing::Lines))
}
});
Self { connector, pending, launches, live }
}
async fn next(&mut self) -> Pending {
tokio::time::timeout(Duration::from_secs(5), self.pending.recv()).await.unwrap().unwrap()
}
}
fn configure(root: &Path, version: &str) {
std::fs::create_dir_all(root.join("agents")).unwrap();
let executable = root.join("catalog-test-bin");
if !executable.exists() {
std::fs::write(&executable, "first executable").unwrap();
}
std::fs::write(
root.join("agents/catalog-test.toml"),
format!(
"name = \"Catalog Test\"\ncommand = {}\n[env]\nTOKEN = \"secret-{version}\"\n",
serde_json::to_string(&executable.to_string_lossy()).unwrap()
),
)
.unwrap();
}
fn make_manager(root: &Path, catalogs: Arc<AgentCatalogs>, harness: &Harness) -> AgentManager {
make_manager_at(root, &root.join("models.json"), catalogs, harness)
}
fn make_manager_at(root: &Path, models_file: &Path, catalogs: Arc<AgentCatalogs>, harness: &Harness) -> AgentManager {
AgentManager::with_connector(
AgentPaths {
project_root: root.to_path_buf(),
store_dir: root.join("store"),
worktrees_dir: root.join("worktrees"),
models_file: models_file.to_path_buf(),
catalogs,
custom_agents_dir: Some(root.join("agents")),
home_dir: Some(root.to_path_buf()),
env_secrets: None,
},
tokio::runtime::Handle::current(),
harness.connector.clone(),
)
}
fn events(manager: &AgentManager) -> mpsc::UnboundedReceiver<AgentEvent> {
let (tx, rx) = mpsc::unbounded_channel();
manager.subscribe(Arc::new(move |event| {
let _ = tx.send(event);
}));
rx
}
async fn completed(events: &mut mpsc::UnboundedReceiver<AgentEvent>) -> AgentModelCatalog {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let AgentEvent::ModelCatalog { catalog } = events.recv().await.unwrap()
&& !catalog.refreshing
{
return catalog;
}
}
})
.await
.unwrap()
}
fn response(models: &[&str], effort: &[&str]) -> Value {
json!({ "sessionId": "discovery-only", "configOptions": [
{ "id": "model", "type": "select", "category": "model", "currentValue": models.first(),
"options": models.iter().map(|m| json!({ "value": m, "name": m })).collect::<Vec<_>>() },
{ "id": "effort", "type": "select", "category": "thought_level", "currentValue": effort.first(),
"options": effort.iter().map(|e| json!({ "value": e })).collect::<Vec<_>>() }
] })
}
fn model_values(catalog: &AgentModelCatalog) -> Vec<&str> {
catalog
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect()
}
async fn stopped(harness: &Harness) {
tokio::time::timeout(Duration::from_secs(5), async {
while harness.live.load(Ordering::SeqCst) != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
fn advance(catalogs: &AgentCatalogs, seconds: u64) {
catalogs.clock_ms.fetch_add(seconds * 1000, Ordering::Relaxed);
}
#[tokio::test]
async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_revert_it() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
let old = config::options_of(&response(&["old", "retained"], &["low", "high"]));
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
let cached = manager.refresh_models(PROVIDER, false).unwrap();
assert!(cached.refreshing);
assert_eq!(model_values(&cached), ["old", "retained"]);
assert!(cached.refreshed_at_ms.is_none(), "session reports are not fresh discovery");
let pending = harness.next().await;
assert_eq!(pending.params["mcpServers"], json!([]));
pending.reply.send(Ok(response(&["retained", "new"], &["medium"]))).unwrap();
let fresh = completed(&mut events).await;
assert_eq!(model_values(&fresh), ["retained", "new"]);
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["new", "retained"]);
assert_eq!(fresh.thought_levels["retained"][0].values[0].value, "medium");
assert!(fresh.refreshed_at_ms.is_some() && fresh.retry_at_ms.is_some());
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
assert_eq!(manager.refresh_models(PROVIDER, false).unwrap().config, fresh.config);
let provider = manager.providers().into_iter().find(|p| p.id == PROVIDER).unwrap();
assert_eq!(provider.config, fresh.config);
let persisted = std::fs::read_to_string(tmp.path().join("models.json")).unwrap();
assert!(!persisted.contains("secret-one") && !persisted.contains("TOKEN"));
let restored = make_manager(tmp.path(), Arc::default(), &harness);
let restored_snapshot = restored.refresh_models(PROVIDER, false).unwrap();
assert!(!restored_snapshot.refreshing, "matching persisted launch context remains fresh");
assert_eq!(restored_snapshot.refreshed_at_ms, fresh.refreshed_at_ms);
let provider = restored.providers().into_iter().find(|p| p.id == PROVIDER).unwrap();
assert_eq!(provider.config, fresh.config);
restored.shared.remember(PROVIDER, Some(&old), None, Some("old"));
assert_eq!(restored.shared.known_config(PROVIDER), fresh.config);
assert_eq!(restored.shared.known.lock()[PROVIDER].refreshed_at_ms, fresh.refreshed_at_ms);
assert!(!tmp.path().join("store").exists(), "refresh never creates tasks");
}
#[tokio::test]
async fn legacy_cache_loads_without_claiming_authoritative_freshness() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let options = config::options_of(&response(&["legacy"], &["low"]));
std::fs::write(
tmp.path().join("models.json"),
serde_json::to_vec(&json!({
(PROVIDER): { "config": options, "thoughtLevels": {}, "projectCommands": {} }
}))
.unwrap(),
)
.unwrap();
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
let mut events = events(&manager);
let cached = manager.refresh_models(PROVIDER, false).unwrap();
assert_eq!(model_values(&cached), ["legacy"]);
assert!(cached.refreshing && cached.refreshed_at_ms.is_none());
harness.next().await.reply.send(Err("Sign in required".into())).unwrap();
let failed = completed(&mut events).await;
assert_eq!(failed.config, cached.config);
assert!(failed.error.as_deref().unwrap().contains("Sign in required"));
}
#[tokio::test]
async fn concurrent_windows_commands_and_manual_requests_share_one_flight_and_cooldown() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = Arc::new(make_manager(tmp.path(), catalogs.clone(), &harness));
let second = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut first_events = events(&first);
let mut second_events = events(&second);
assert!(first.refresh_models(PROVIDER, false).unwrap().refreshing);
let pending = harness.next().await;
for _ in 0..10 {
assert!(second.refresh_models(PROVIDER, true).unwrap().refreshing);
assert!(first.refresh_models(PROVIDER, true).unwrap().refreshing);
}
let commands = tokio::spawn({
let first = first.clone();
async move { first.commands(PROVIDER, None).await }
});
tokio::task::yield_now().await;
assert_eq!(harness.launches.load(Ordering::SeqCst), 1);
pending.reply.send(Ok(response(&["one"], &["low"]))).unwrap();
let done = completed(&mut first_events).await;
assert_eq!(completed(&mut second_events).await, done);
commands.await.unwrap().unwrap();
for force in [false, true, false, true] {
assert!(!second.refresh_models(PROVIDER, force).unwrap().refreshing);
}
advance(&catalogs, 29);
assert!(!first.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 1);
assert!(!first.refresh_models(PROVIDER, false).unwrap().refreshing, "automatic refresh remains fresh");
assert!(first.refresh_models(PROVIDER, true).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["two"], &["high"]))).unwrap();
let newer = completed(&mut first_events).await;
assert_eq!(model_values(&newer), ["two"]);
assert_eq!(completed(&mut second_events).await, newer);
advance(&catalogs, 15 * 60 - 1);
assert!(!second.refresh_models(PROVIDER, false).unwrap().refreshing);
advance(&catalogs, 1);
assert!(second.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["three"], &["low"]))).unwrap();
completed(&mut first_events).await;
assert_eq!(harness.launches.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn failure_keeps_successful_choices_and_throttles_manual_and_auto_retries() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&["cached"], &["low"]))).unwrap();
let success = completed(&mut events).await;
let disk = std::fs::read(tmp.path().join("models.json")).unwrap();
advance(&catalogs, 15 * 60);
manager.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Err("Authentication expired".into())).unwrap();
let failed = completed(&mut events).await;
assert_eq!(failed.config, success.config);
assert_eq!(failed.thought_levels, success.thought_levels);
assert_eq!(failed.refreshed_at_ms, success.refreshed_at_ms);
assert!(failed.error.as_deref().unwrap().contains("Authentication expired"));
assert_eq!(std::fs::read(tmp.path().join("models.json")).unwrap(), disk);
assert!(!manager.refresh_models(PROVIDER, false).unwrap().refreshing);
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 29);
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 1);
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["recovered"], &["high"]))).unwrap();
let recovered = completed(&mut events).await;
assert_eq!(model_values(&recovered), ["recovered"]);
assert!(recovered.error.is_none());
}
#[tokio::test]
async fn timeout_cleans_up_and_enters_the_same_failure_cooldown() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let pending = harness.next().await;
tokio::time::pause();
tokio::time::advance(Duration::from_secs(4)).await;
let failed = completed(&mut events).await;
assert!(failed.error.as_deref().unwrap().contains("time"));
assert!(failed.retry_at_ms.is_some());
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
assert!(!manager.refresh_models(PROVIDER, false).unwrap().refreshing);
tokio::time::resume();
stopped(&harness).await;
assert_eq!(harness.live.load(Ordering::SeqCst), 0, "timed-out connection must close");
assert!(pending.reply.is_closed());
advance(&catalogs, 30);
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["retry"], &["low"]))).unwrap();
completed(&mut events).await;
}
#[tokio::test]
async fn changed_effective_config_supersedes_old_completion_without_queueing() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = make_manager(tmp.path(), catalogs.clone(), &harness);
let second = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&second);
first.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
configure(tmp.path(), "two");
assert!(second.refresh_models(PROVIDER, false).unwrap().refreshing);
let latest = harness.next().await;
latest.reply.send(Ok(response(&["latest"], &["high"]))).unwrap();
let fresh = completed(&mut events).await;
let _ = obsolete.reply.send(Ok(response(&["obsolete"], &["low"])));
stopped(&harness).await;
let shown = first.refresh_models(PROVIDER, false).unwrap();
assert_eq!(shown.config, fresh.config);
assert_eq!(shown.refreshed_at_ms, fresh.refreshed_at_ms);
assert!(!shown.refreshing);
assert_eq!(harness.launches.load(Ordering::SeqCst), 2);
assert!(!std::fs::read_to_string(tmp.path().join("models.json")).unwrap().contains("obsolete"));
// A changed configuration also invalidates successful freshness/cooldown.
configure(tmp.path(), "three");
assert!(first.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["new-config"], &["medium"]))).unwrap();
completed(&mut events).await;
}
#[tokio::test]
async fn invalid_provider_is_a_request_error_and_does_not_launch() {
let tmp = tempfile::tempdir().unwrap();
let harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
assert!(manager.refresh_models("missing-provider", true).is_err());
assert_eq!(harness.launches.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn replacing_the_executable_at_the_same_path_invalidates_freshness_and_obsolete_responses() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "unchanged-environment");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs, &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
std::fs::write(tmp.path().join("catalog-test-bin"), "replacement executable with different size").unwrap();
manager.refresh_models(PROVIDER, false).unwrap();
let latest = harness.next().await;
latest.reply.send(Ok(response(&["updated"], &["high"]))).unwrap();
let fresh = completed(&mut events).await;
let _ = obsolete.reply.send(Ok(response(&["obsolete"], &["low"])));
stopped(&harness).await;
assert_eq!(manager.refresh_models(PROVIDER, false).unwrap().config, fresh.config);
std::fs::write(tmp.path().join("catalog-test-bin"), "another executable").unwrap();
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["new-install"], &["medium"]))).unwrap();
assert_eq!(model_values(&completed(&mut events).await), ["new-install"]);
assert_eq!(harness.launches.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn project_catalogs_and_persisted_freshness_do_not_leak_between_contexts() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("project-a");
let b = tmp.path().join("project-b");
configure(&a, "account-a");
configure(&b, "account-b");
let models_file = tmp.path().join("shared-models.json");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = make_manager_at(&a, &models_file, catalogs.clone(), &harness);
let second = make_manager_at(&b, &models_file, catalogs, &harness);
let mut first_events = events(&first);
let mut second_events = events(&second);
first.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["account-a-model"], &["low"]))).unwrap();
let a_catalog = completed(&mut first_events).await;
second.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["account-b-model"], &["high"]))).unwrap();
let b_catalog = completed(&mut second_events).await;
assert_eq!(first.refresh_models(PROVIDER, false).unwrap().config, a_catalog.config);
assert_eq!(second.refresh_models(PROVIDER, false).unwrap().config, b_catalog.config);
assert_eq!(first.providers().into_iter().find(|p| p.id == PROVIDER).unwrap().config, a_catalog.config);
for (root, expected) in [(&a, &a_catalog), (&b, &b_catalog)] {
let restored = make_manager_at(root, &models_file, Arc::default(), &harness);
let cached = restored.refresh_models(PROVIDER, false).unwrap();
assert!(!cached.refreshing, "matching persisted context should retain freshness");
assert_eq!(cached.config, expected.config);
assert_eq!(cached.refreshed_at_ms, expected.refreshed_at_ms);
}
assert_eq!(harness.launches.load(Ordering::SeqCst), 2);
configure(&a, "new-account");
let changed = make_manager_at(&a, &models_file, Arc::default(), &harness);
let mut events = events(&changed);
let stale = changed.refresh_models(PROVIDER, false).unwrap();
assert!(stale.refreshing, "a different launch environment cannot reuse persisted freshness");
assert!(stale.refreshed_at_ms.is_none());
assert_eq!(stale.config, a_catalog.config, "stale cached choices remain selectable");
harness.next().await.reply.send(Ok(response(&["new-account-model"], &["medium"]))).unwrap();
completed(&mut events).await;
let persisted = std::fs::read_to_string(models_file).unwrap();
assert!(!persisted.contains("secret-") && !persisted.contains("TOKEN"));
}
#[tokio::test]
async fn configuration_changed_without_another_request_also_discards_old_discovery() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "before");
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
configure(tmp.path(), "after");
obsolete.reply.send(Ok(response(&["obsolete"], &["low"]))).unwrap();
let discarded = completed(&mut events).await;
assert!(model_values(&discarded).is_empty());
assert!(discarded.refreshed_at_ms.is_none());
assert!(!tmp.path().join("models.json").exists());
manager.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["current"], &["high"]))).unwrap();
assert_eq!(model_values(&completed(&mut events).await), ["current"]);
}
#[tokio::test]
async fn successful_empty_catalog_removes_all_previous_models_and_efforts() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&["removed"], &["low"]))).unwrap();
completed(&mut events).await;
advance(&catalogs, 30);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&[], &[]))).unwrap();
let empty = completed(&mut events).await;
assert!(model_values(&empty).is_empty() && empty.thought_levels.is_empty());
assert!(empty.refreshed_at_ms.is_some() && empty.error.is_none());
}
+149 -63
Переглянути файл
@@ -8,7 +8,6 @@
//! next prompt when the agent supports `session/load`.
mod acp;
mod catalogs;
mod config;
mod conversation;
mod external;
@@ -22,7 +21,6 @@ mod skills;
mod timeline;
mod worktree;
pub use catalogs::AgentCatalogs;
pub use external::ExternalSession;
pub use model::*;
@@ -90,8 +88,6 @@ pub struct AgentPaths {
pub worktrees_dir: PathBuf,
/// Settings each agent offered last time (shared by all projects), and its commands per project.
pub models_file: PathBuf,
/// Shared by every project/window on this backend.
pub catalogs: Arc<AgentCatalogs>,
/// User-defined agents, one `<id>.toml` each (`name`, `command`, `args`, `env`).
pub custom_agents_dir: Option<PathBuf>,
/// The user's home: its `.claude` holds their own skills and commands.
@@ -127,10 +123,12 @@ struct Shared {
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
new_task_approvals: RwLock<Option<NewTaskApprovalsHook>>,
known: Arc<Mutex<HashMap<String, Known>>>,
known: Mutex<HashMap<String, Known>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session lookup of commands and settings per provider, including failed attempts.
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
mcp_servers_hook: RwLock<Option<Arc<McpServersFn>>>,
@@ -147,14 +145,6 @@ struct Known {
/// By project root: skills and custom commands differ per project.
#[serde(default, rename = "projectCommands")]
commands: HashMap<String, Vec<AgentCommand>>,
/// Successful discovery, unlike a session report, owns availability.
#[serde(default)]
authoritative: bool,
#[serde(default, rename = "refreshedAtMs")]
refreshed_at_ms: Option<f64>,
/// Authoritative availability and freshness are scoped to project and launch context.
#[serde(default, rename = "projectCatalogs")]
catalogs: HashMap<String, catalogs::SavedCatalog>,
}
impl Shared {
@@ -181,11 +171,7 @@ impl Shared {
}
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let config = self.paths.catalogs.cached(self, provider).config;
self.config_for_new_task(config, provider)
}
fn config_for_new_task(&self, mut config: Vec<AgentConfigOption>, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
// Model-specific options are offered separately, never as provider-wide defaults.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config::add_client_options(provider, &mut config);
@@ -211,6 +197,80 @@ impl Shared {
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
let mut ready = {
let mut fetches = self.command_fetches.lock();
fetches
.entry(spec.id.clone())
.or_insert_with(|| {
let (done, ready) = tokio::sync::watch::channel(false);
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
tracing::info!("agent {}: settings discovery started", spec.id);
let discovered = if spec.driver == provider::Driver::Acp {
acp::discover::run(&shared, &spec).await.map(|_| None)
} else if spec.driver == provider::Driver::Claude {
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
} else {
return;
};
if let Ok(Some(found)) = discovered {
let commands =
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": found.options }));
let levels: Vec<_> = found
.efforts
.into_iter()
.map(|(model, o)| {
let options = o.map(|o| config::options_of(&json!({ "configOptions": [o] })));
(model, options.unwrap_or_default())
})
.collect();
let _ = tokio::task::spawn_blocking(move || {
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands, &levels);
})
.await;
} else if let Err(e) = discovered {
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
}
let _ = done.send(true);
});
ready
})
.clone()
};
while !*ready.borrow_and_update() {
if ready.changed().await.is_err() {
break;
}
}
}
fn has_config(&self, provider: &str) -> bool {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
/// Whether the thinking levels of every model the agent offers are known (#1686).
fn knows_all_levels(&self, provider: &str) -> bool {
let cache = self.known.lock();
let Some(known) = cache.get(provider) else { return false };
let models: Vec<&str> = known
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
// No advertised models yet: nothing to attach effort levels to.
if models.is_empty() {
return false;
}
models.iter().all(|id| known.thought_levels.contains_key(*id))
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -223,48 +283,78 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
self.remember_inner(provider, config, commands, model, &[], false);
}
/// `levels`: each model's thinking-level options, so a new task offers them for any model
/// it picks (#1686). A session's report for a model wins over these.
fn remember_discovered(
&self,
provider: &str,
config: &[AgentConfigOption],
commands: &[AgentCommand],
levels: &[(String, Vec<AgentConfigOption>)],
) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), levels, true);
}
fn remember_inner(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered_levels: &[(String, Vec<AgentConfigOption>)],
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
// Running tasks retain their own reported settings. Once discovery has
// succeeded, a session's older list/defaults cannot change New Task choices.
if !known.authoritative
&& let Some(config) = config.filter(|c| !c.is_empty())
{
for (model, levels) in discovered_levels {
if !known.thought_levels.contains_key(model) {
known.thought_levels.insert(model.clone(), levels.clone());
changed = true;
}
}
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
if let Some(model) = model {
let levels: Vec<_> =
config.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if known.config != config {
known.config = config.to_vec();
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
}
if let Some(commands) = commands.filter(|c| !c.is_empty())
&& known.commands.get(&self.root_key()).map(Vec::as_slice) != Some(commands)
{
known.commands.insert(self.root_key(), commands.to_vec());
let root = self.root_key();
if let Some(c) = commands.filter(|c| {
!c.is_empty()
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
}) {
known.commands.insert(root, c.to_vec());
changed = true;
}
if changed {
self.persist_known(&cache);
if !changed {
return;
}
drop(cache);
if changed {
self.emit(AgentEvent::Providers);
}
}
fn persist_known(&self, cache: &HashMap<String, Known>) {
if let Ok(bytes) = serde_json::to_vec_pretty(cache)
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
{
tracing::warn!("saving agent settings: {e}");
}
// The cached provider config actually changed (a session report, discovery,
// or a turn update): tell subscribers to re-list, so a changed model list
// reaches the composer without waiting for the next task (#1490). Unchanged
// reports stay silent, so routine session traffic emits nothing. The cache
// lock is released first; the sink never runs under it.
drop(cache);
self.emit(AgentEvent::Providers);
}
}
@@ -379,7 +469,8 @@ impl AgentManager {
}
fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self {
let known = paths.catalogs.store(&paths.models_file);
let known =
std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref(), paths.env_secrets.as_deref()));
let shared = Arc::new(Shared {
paths,
@@ -388,13 +479,13 @@ impl AgentManager {
sink: RwLock::new(None),
security: RwLock::new(None),
new_task_approvals: RwLock::new(None),
known,
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
command_fetches: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
mcp_servers_hook: RwLock::new(None),
});
shared.paths.catalogs.subscribe(&shared);
let mut tasks = HashMap::new();
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
let path = file.path();
@@ -451,9 +542,9 @@ impl AgentManager {
.filter(|p| !provider::is_unlisted(p) || in_use.contains(&p.id))
.map(|p| {
let mut info = provider::describe(p);
let catalog = self.shared.paths.catalogs.cached(&self.shared, &p.id);
info.config = self.shared.config_for_new_task(catalog.config, &p.id);
info.thought_levels = catalog.thought_levels;
info.config = self.shared.known_config(&p.id);
info.thought_levels =
self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default();
info.commands = self.shared.known_commands(&p.id);
info
})
@@ -541,20 +632,17 @@ impl AgentManager {
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& let Some(spec) =
provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets())
.into_iter()
.find(|spec| spec.id == provider)
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
&& let Some(spec) = self
.shared
.spec(&provider)
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
{
let (_, flight) = self.shared.paths.catalogs.request(&self.shared, spec, false);
if let Some(mut ready) = flight {
while !*ready.borrow_and_update() {
if ready.changed().await.is_err() {
break;
}
}
let claude = spec.driver == provider::Driver::Claude;
self.shared.fetch_settings(spec).await;
if claude {
offered = self.shared.known_commands(&provider);
}
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
@@ -706,18 +794,16 @@ impl AgentManager {
}
};
// Until the session reports its own, show what the agent offered last time with the choices applied.
let catalog = self.shared.paths.catalogs.cached(&self.shared, &spec.id);
let mut levels = catalog.thought_levels;
let mut config = self.shared.config_for_new_task(catalog.config, &spec.id);
let mut config = self.shared.known_config(&spec.id);
for choice in &req.config {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = levels.remove(&model)
&& let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
{
config.extend(levels.into_iter().map(|mut o| {
config.extend(levels.iter().cloned().map(|mut o| {
if let Some(choice) =
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
{
-17
Переглянути файл
@@ -538,19 +538,6 @@ pub struct AgentChanges {
pub commits: Vec<AgentCommit>,
}
/// Authoritative choices for new tasks, independent of any running session's settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelCatalog {
pub provider: String,
pub config: Vec<AgentConfigOption>,
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
pub refreshing: bool,
pub refreshed_at_ms: Option<f64>,
pub retry_at_ms: Option<f64>,
pub error: Option<String>,
}
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
#[derive(Clone, Debug, Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
@@ -588,10 +575,6 @@ pub enum AgentEvent {
provider: String,
limits: Vec<AgentLimit>,
},
/// A model lookup started or completed, including unsuccessful lookups.
ModelCatalog {
catalog: AgentModelCatalog,
},
/// The installed or configured agent providers changed.
Providers,
}
-2
Переглянути файл
@@ -85,8 +85,6 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discove
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
let mut args = spec.args;
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
// Metadata discovery must not launch project-configured MCP servers.
args.extend(["--strict-mcp-config".into(), "--mcp-config".into(), "{\"mcpServers\":{}}".into()]);
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
+37 -98
Переглянути файл
@@ -44,92 +44,6 @@ pub(crate) fn connect(
})
}
/// A model lookup uses app-server directly: no thread, task, prompt or project MCP.
pub(crate) async fn discover(
spec: ProviderSpec,
cwd: std::path::PathBuf,
) -> Result<crate::catalogs::Discovered, String> {
let spec = tokio::task::spawn_blocking({
let cwd = cwd.clone();
move || spec.resolve_for_launch(&cwd)
})
.await
.map_err(|e| e.to_string())?;
let peer = Peer::spawn_framed(
&spec.command,
&spec.args,
&cwd,
&spec.env,
Arc::new(ide_jsonrpc::NoopHandler),
Box::new(|_| {}),
Framing::Lines,
)
.map_err(|e| e.to_string())?;
let _stop = crate::catalogs::KillPeer(peer.clone());
tokio::time::timeout(Duration::from_secs(30), async {
let _: Value = peer.request("initialize", initialize_params(), None).await.map_err(|e| e.to_string())?;
peer.notify("initialized", Value::Null).map_err(|e| e.to_string())?;
let mut models = list_models(&peer).await.map_err(|e| e.message)?;
models.retain(|model| !model.hidden);
let mut settings = Settings::of(&json!({}), &models);
if settings.model.is_empty()
&& let Some(model) = models.first()
{
settings.model = model.id.clone();
settings.fit_effort(&models);
}
let options = translate::config_options(&models, &settings);
let config = crate::config::options_of(&json!({ "configOptions": options }));
let levels = models
.iter()
.map(|model| {
let mut settings = settings.clone();
settings.model = model.id.clone();
settings.fit_effort(&models);
let options = translate::config_options(&models, &settings);
let levels = crate::config::options_of(&json!({ "configOptions": options }))
.into_iter()
.filter(|o| o.category.as_deref() == Some("thought_level"))
.collect();
(model.id.clone(), levels)
})
.collect();
Ok(crate::catalogs::Discovered { config, commands: None, levels })
})
.await
.map_err(|_| "Codex model discovery timed out".to_owned())?
}
fn initialize_params() -> Value {
json!({
"clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
"capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT },
})
}
async fn list_models(peer: &Peer) -> Result<Vec<ModelInfo>, ResponseError> {
let mut all = Vec::new();
let mut cursor = Value::Null;
let mut seen = HashSet::new();
loop {
let page: Value = peer.request("model/list", json!({ "cursor": cursor }), None).await.map_err(from_codex)?;
let Some(data) = page.get("data").and_then(Value::as_array) else {
return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model list"));
};
let models = translate::models_of(&page);
if models.len() != data.len() {
return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model list"));
}
all.extend(models);
match page.get("nextCursor") {
None | Some(Value::Null) => return Ok(all),
Some(Value::String(next)) if next.is_empty() => return Ok(all),
Some(Value::String(next)) if seen.insert(next.clone()) => cursor = json!(next),
_ => return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model-list cursor")),
}
}
}
/// Connects `client` to a shim driving the codex connection `connect_codex` opens (it
/// gets the handler for what codex sends). Each side's end closes the other.
fn attach(
@@ -336,7 +250,15 @@ impl CodexShim {
}
async fn initialize(&self) -> Result<Value, ResponseError> {
let info = self.call("initialize", initialize_params()).await?;
let info = self
.call(
"initialize",
json!({
"clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
"capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT },
}),
)
.await?;
self.codex().notify("initialized", Value::Null).map_err(from_codex)?;
self.state.lock().initialized = true;
// `<client>/<codex version> (<platform>) …`
@@ -360,15 +282,32 @@ impl CodexShim {
}))
}
/// Cache only complete, successful listings; a failed page must never become availability.
async fn models(&self) -> Result<Arc<Vec<ModelInfo>>, ResponseError> {
/// The models codex offers; empty when it can't list them.
async fn models(&self) -> Arc<Vec<ModelInfo>> {
let mut cached = self.models.lock().await;
if let Some(models) = cached.as_ref() {
return Ok(models.clone());
if let Some(m) = cached.as_ref() {
return m.clone();
}
let models = Arc::new(list_models(self.codex()).await?);
let mut all = Vec::new();
let mut cursor = Value::Null;
loop {
match self.call("model/list", json!({ "cursor": cursor })).await {
Ok(page) => {
all.extend(translate::models_of(&page));
match page.get("nextCursor").filter(|c| c.is_string()) {
Some(next) if *next != cursor => cursor = next.clone(),
_ => break,
}
}
Err(e) => {
tracing::warn!("codex: listing models failed: {}", e.message);
return Arc::new(all);
}
}
}
let models = Arc::new(all);
*cached = Some(models.clone());
Ok(models)
models
}
async fn publish_skills(&self, session: &str) {
@@ -402,7 +341,7 @@ impl CodexShim {
}
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
let models = self.models().await?;
let models = self.models().await;
let mut request = json!({ "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
@@ -419,7 +358,7 @@ impl CodexShim {
/// Resumes the thread and replays its history before answering.
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
let id = str_param(params, "sessionId")?;
let models = self.models().await?;
let models = self.models().await;
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
@@ -437,7 +376,7 @@ impl CodexShim {
async fn fork_session(&self, params: &Value) -> Result<Value, ResponseError> {
let source = str_param(params, "sessionId")?;
let models = self.models().await?;
let models = self.models().await;
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
@@ -458,7 +397,7 @@ impl CodexShim {
let session = str_param(params, "sessionId")?;
let option = str_param(params, "configId")?;
let value = str_param(params, "value")?;
let models = self.models().await?;
let models = self.models().await;
let mut state = self.state.lock();
let settings = state
.threads
@@ -475,7 +414,7 @@ impl CodexShim {
if input.is_empty() {
return Err(invalid("empty prompt"));
}
let models = self.models().await?;
let models = self.models().await;
let (done, ended) = oneshot::channel();
let settings = {
let mut state = self.state.lock();
-149
Переглянути файл
@@ -11,155 +11,6 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
struct ModelPages {
pages: Mutex<VecDeque<Result<Value, (i64, String)>>>,
cursors: Mutex<Vec<Value>>,
}
impl Handler for ModelPages {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
assert_eq!(method, "model/list", "listing models must never start a thread or turn");
self.cursors.lock().push(params["cursor"].clone());
self.pages.lock().pop_front().expect("another expected page")
}
}
fn model_peer(pages: Arc<ModelPages>) -> (Peer, Peer) {
let (ours, theirs) = tokio::io::duplex(1 << 16);
let (read, write) = tokio::io::split(theirs);
let server = Peer::connect_framed(read, write, pages, Framing::Lines);
let (read, write) = tokio::io::split(ours);
(Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines), server)
}
#[tokio::test]
async fn model_pagination_failure_does_not_cache_an_incomplete_catalog() {
let first = json!({ "data": [{ "id": "first" }], "nextCursor": "next" });
let pages = Arc::new(ModelPages {
pages: Mutex::new(VecDeque::from([
Ok(first.clone()),
Err((-32000, "authentication expired".into())),
Ok(first),
Ok(json!({ "data": [{ "id": "second" }], "nextCursor": null })),
])),
cursors: Mutex::default(),
});
let (peer, server) = model_peer(pages.clone());
let shim = CodexShim {
runtime: tokio::runtime::Handle::current(),
codex: OnceLock::from(peer.clone()),
client: OnceLock::new(),
state: Mutex::default(),
models: tokio::sync::Mutex::new(None),
skills: tokio::sync::Mutex::new(()),
};
assert_eq!(shim.models().await.unwrap_err().message, "authentication expired");
let models = shim.models().await.unwrap();
assert_eq!(models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["first", "second"]);
assert_eq!(shim.models().await.unwrap().len(), 2);
assert_eq!(*pages.cursors.lock(), [Value::Null, json!("next"), Value::Null, json!("next")]);
peer.kill();
server.kill();
}
#[tokio::test]
async fn repeated_or_invalid_model_cursors_and_malformed_pages_are_failures() {
for pages in [
vec![json!({ "data": [], "nextCursor": "a" }), json!({ "data": [], "nextCursor": "a" })],
vec![
json!({ "data": [], "nextCursor": "a" }),
json!({ "data": [], "nextCursor": "b" }),
json!({ "data": [], "nextCursor": "a" }),
],
vec![json!({ "data": [], "nextCursor": 7 })],
vec![json!({ "nextCursor": null })],
vec![json!({ "data": [{}], "nextCursor": null })],
] {
let pages =
Arc::new(ModelPages { pages: Mutex::new(pages.into_iter().map(Ok).collect()), cursors: Mutex::default() });
let (peer, server) = model_peer(pages);
assert!(list_models(&peer).await.is_err());
peer.kill();
server.kill();
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn native_discovery_only_initializes_and_pages_models_then_stops_the_process() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let script = tmp.path().join("fake-codex");
std::fs::write(
&script,
r#"#!/usr/bin/env python3
import json, os, pathlib, sys
pathlib.Path('pid').write_text(str(os.getpid()))
for line in sys.stdin:
request = json.loads(line)
with pathlib.Path('requests').open('a') as f:
f.write(json.dumps(request) + '\n')
method = request['method']
if method == 'initialized':
continue
if method == 'initialize':
result = {}
elif method == 'model/list':
cursor = request['params']['cursor']
model = 'one' if cursor is None else 'two'
result = {'data': [{'id': model, 'isDefault': cursor is None,
'defaultReasoningEffort': 'low', 'supportedReasoningEfforts': [{'reasoningEffort': 'low'}]}],
'nextCursor': 'second-page' if cursor is None else None}
else:
raise RuntimeError('must not create a thread or prompt: ' + method)
print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], 'result': result}), flush=True)
"#,
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let spec = ProviderSpec {
id: "codex".into(),
name: "Codex".into(),
driver: crate::provider::Driver::Codex,
command: script.to_string_lossy().into_owned(),
args: Vec::new(),
env: Vec::new(),
login_hint: String::new(),
source: crate::AgentSource::Builtin,
};
let found = discover(spec, tmp.path().to_path_buf()).await.unwrap();
let models: Vec<_> = found
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
assert_eq!(models, ["one", "two"]);
assert_eq!(found.levels.keys().map(String::as_str).collect::<Vec<_>>(), ["one", "two"]);
let requests = std::fs::read_to_string(tmp.path().join("requests")).unwrap();
let requests: Vec<Value> = requests.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(
requests.iter().map(|r| r["method"].as_str().unwrap()).collect::<Vec<_>>(),
["initialize", "initialized", "model/list", "model/list"]
);
assert_eq!(requests[2]["params"]["cursor"], Value::Null);
assert_eq!(requests[3]["params"]["cursor"], "second-page");
let pid = std::fs::read_to_string(tmp.path().join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success();
if !alive {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("discovery must kill and reap its child");
}
// ---- Pure translations -------------------------------------------------------------
fn models() -> Vec<ModelInfo> {
-1
Переглянути файл
@@ -25,7 +25,6 @@ fn paths(dir: &Path) -> AgentPaths {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
}
}
+62 -18
Переглянути файл
@@ -405,7 +405,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
// user's profile, when temp lives under it) leaks into the test.
home_dir: Some(dir.to_path_buf()),
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -443,7 +442,7 @@ async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_share_catalog_discovery_and_persist_results() {
async fn claude_commands_before_session_are_fetched_once_and_saved() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
@@ -503,18 +502,14 @@ for line in sys.stdin:
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
.unwrap();
let mgr = manager(root, Arc::new(FakeAgent::default()));
if outcome != "missing" {
provider::set_builtin_command(&root.join("agents"), None, "claude", Some(&script.to_string_lossy()))
.unwrap();
{
let mut providers = mgr.shared.providers.write();
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
if outcome != "missing" {
spec.command = script.to_string_lossy().into_owned();
}
spec.env = vec![("OUTCOME".into(), outcome.into())];
}
provider::set_builtin_env(
&root.join("agents"),
None,
"claude",
BTreeMap::from([("OUTCOME".into(), outcome.into())]),
)
.unwrap();
mgr.reload_providers();
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(10), async {
@@ -558,6 +553,7 @@ for line in sys.stdin:
effort.unwrap().current.as_deref(),
Some(match outcome {
"settings_timeout" => "default",
"session_report" => "low",
_ => "high",
})
);
@@ -575,8 +571,6 @@ for line in sys.stdin:
assert_eq!(launches.lines().count(), 1, "{outcome}");
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
assert!(args.iter().any(|a| a == "--strict-mcp-config"));
assert!(args.windows(2).any(|a| a == ["--mcp-config", "{\"mcpServers\":{}}"]));
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
let subtypes: Vec<String> = requests
.lines()
@@ -1437,7 +1431,6 @@ async fn real_claude_task_in_a_worktree() {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
);
@@ -2272,7 +2265,6 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -2605,7 +2597,6 @@ async fn delete_running_agent(no_load: bool) {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -2668,6 +2659,34 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
for discovery_first in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let options = |model: &str, effort: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
"options": [{ "value": "saved" }, { "value": "discovered" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
"options": [{ "value": "low" }, { "value": "high" }] }
] }))
};
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn provider_reload_does_not_start_acp_discovery() {
let agent = Arc::new(FakeAgent::default());
@@ -2754,6 +2773,31 @@ async fn discovery_times_out_when_session_new_hangs() {
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
}
#[tokio::test(flavor = "multi_thread")]
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let effort = |values: &[&str], current: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": values.iter().map(|v| json!({ "value": v })).collect::<Vec<_>>() }
] }))
};
let model = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "sonnet",
"options": [{ "value": "sonnet" }, { "value": "opus" }] }
] }));
// A session already reported sonnet's levels; discovery adds opus's.
let reported = [model.clone(), effort(&["low", "high"], "high")].concat();
mgr.shared.remember("claude", Some(&reported), None, Some("sonnet"));
let levels =
[("sonnet".to_owned(), effort(&["low"], "low")), ("opus".to_owned(), effort(&["low", "max"], "default"))];
mgr.shared.remember_discovered("claude", &model, &[], &levels);
let known = mgr.shared.known.lock();
assert_eq!(known["claude"].thought_levels["sonnet"], effort(&["low", "high"], "high"), "the session's report wins");
assert_eq!(known["claude"].thought_levels["opus"], effort(&["low", "max"], "default"));
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
+1 -51
Переглянути файл
@@ -53,8 +53,6 @@ pub enum BuildSystem {
TypeScript,
#[serde(rename = "cmake")]
CMake,
#[serde(rename = "dotnet")]
Dotnet,
}
/// One command of a build.
@@ -167,8 +165,6 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
{
Some(BuildSystem::Gradle)
} else if dotnet_target(dir).is_some() {
Some(BuildSystem::Dotnet)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod", "go.work"]) {
@@ -229,7 +225,7 @@ pub fn plan(
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| {
IdeError::not_found("no supported build system (Maven, Gradle, .NET, Cargo, Go, TypeScript, CMake) found")
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
})?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
@@ -248,7 +244,6 @@ pub fn plan(
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
BuildSystem::Dotnet => dotnet_steps(&dir, kind)?,
};
Ok(BuildPlan { system, title, steps })
}
@@ -456,33 +451,6 @@ fn has_build_script(dir: &Path) -> bool {
.is_some_and(|v| v.pointer("/scripts/build").is_some_and(serde_json::Value::is_string))
}
/// The solution (`.sln` / `.slnx`) or project (`.csproj` / `.fsproj` / `.vbproj`) file `dir`
/// builds: a solution first, then a project; the first by name when there are several.
fn dotnet_target(dir: &Path) -> Option<PathBuf> {
let mut files: Vec<PathBuf> =
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
files.sort();
let ext = |p: &Path| p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let solution = files.iter().find(|p| matches!(ext(p).as_str(), "sln" | "slnx"));
solution.or_else(|| files.iter().find(|p| matches!(ext(p).as_str(), "csproj" | "fsproj" | "vbproj"))).cloned()
}
/// `dotnet build` of the directory's solution or project; Rebuild skips incremental builds,
/// Build Artifacts publishes. The summary that repeats every message is left out.
fn dotnet_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
let target = dotnet_target(dir).ok_or_else(|| IdeError::not_found("no .NET solution or project found"))?;
let target = target.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
let verb = if kind == BuildKind::Artifacts { "publish" } else { "build" };
let mut step = BuildStep::new("dotnet", &[verb, &target, "-nologo", "-clp:NoSummary"], dir, Format::Msbuild);
if kind == BuildKind::Rebuild {
step.args.push("--no-incremental".into());
}
// The classic console logger prints the canonical `file(line,col): error` lines; the
// terminal logger (.NET 8+) only appears on a terminal, but rules it out for older SDKs too.
step.env.insert("MSBUILDTERMINALLOGGER".into(), "off".into());
Ok(vec![step])
}
fn ts_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let script = has_build_script(dir);
if script && (kind == BuildKind::Artifacts || !dir.join("tsconfig.json").is_file()) {
@@ -1173,24 +1141,6 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
// .NET: the solution, else the project; a module build is the file's project. An ASP.NET
// project's package.json (its front end) doesn't make it a TypeScript build.
let net = tmp.path().join("net");
write(&net, "App.sln", "");
write(&net, "src/Api/Api.csproj", "<Project/>");
write(&net, "src/Api/package.json", "{}");
write(&net, "src/Api/Program.cs", "");
let p = plan(&net, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Dotnet);
assert_eq!(p.steps[0].args, ["build", "App.sln", "-nologo", "-clp:NoSummary"]);
assert_eq!(p.steps[0].env["MSBUILDTERMINALLOGGER"], "off");
let p = plan(&net, &BuildModel::default(), BuildKind::Rebuild, Some(&net.join("src/Api/Program.cs")), None)
.unwrap();
assert_eq!(p.steps[0].args, ["build", "Api.csproj", "-nologo", "-clp:NoSummary", "--no-incremental"]);
assert_eq!(p.steps[0].cwd, net.join("src/Api"));
let p = plan(&net, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
assert_eq!(p.steps[0].args[0], "publish");
let cm = tmp.path().join("cm");
write(&cm, "CMakeLists.txt", "project(app)");
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
-61
Переглянути файл
@@ -25,8 +25,6 @@ pub(crate) enum Format {
Go,
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
Gcc,
/// MSBuild (`dotnet build`): `C:\p\A.cs(12,5): error CS1002: msg [C:\p\App.csproj]`.
Msbuild,
}
/// What a printed column counts.
@@ -270,43 +268,6 @@ fn tsc_line(line: &str) -> Option<RawMessage> {
Some(m)
}
/// MSBuild's canonical form: `path(12,5): error CS1002: msg [project]`, `path(12,5,12,9): …`,
/// or `path : error NU1101: msg [project]` for a project-level message (no position).
/// The path may contain spaces (`C:\Users\Jane Doe\…`); "error" / "warning" are never localized.
fn msbuild_line(line: &str) -> Option<RawMessage> {
let line = line.trim();
// The project the message came from, appended by the console logger.
let line = match line.rfind(" [") {
Some(i) if line.ends_with(']') => &line[..i],
_ => line,
};
let (head, severity, rest) =
[(" error ", Severity::Error), (" warning ", Severity::Warning)].into_iter().find_map(|(word, severity)| {
let i = line.find(&format!(":{word}"))?;
Some((&line[..i], severity, &line[i + word.len() + 1..]))
})?;
let (code, msg) = rest.split_once(':')?;
let code = code.trim();
if code.is_empty() || code.contains(' ') {
return None;
}
let head = head.trim_end();
let (path, pos) = match head.strip_suffix(')').and_then(|h| h.rsplit_once('(')) {
Some((path, pos)) => (path, pos.split(',').map(num).collect::<Vec<_>>()),
None => (head, Vec::new()),
};
let at = |i: usize| pos.get(i).copied().flatten();
// `MSBUILD : error MSB1009` and the like name no file.
let file = path.rsplit(['/', '\\']).next().is_some_and(|n| n.contains('.'));
let mut m = RawMessage::new(severity, msg.trim(), ColUnit::Utf16);
if file {
m.path = Some(path.trim().to_owned());
(m.line, m.col, m.end_line, m.end_col) = (at(0), at(1), at(2), at(3));
}
m.code = Some(code.to_owned());
Some(m)
}
/// go: `./a.go:12:5: msg` (errors only; `go vet` style `a.go:12: msg` too).
fn go_line(line: &str) -> Option<RawMessage> {
let (path, ln, col, rest) = path_line_col(line)?;
@@ -443,7 +404,6 @@ impl OutputParser {
Format::Tsc => tsc_line(&line),
Format::Go => go_line(&line),
Format::Gcc => gcc_line(&line),
Format::Msbuild => msbuild_line(&line),
Format::Cargo => None,
};
if let Some(msg) = parsed {
@@ -591,27 +551,6 @@ mod tests {
assert!(plain.messages.is_empty() && plain.console.is_none());
}
#[test]
fn msbuild_canonical_messages() {
let out = parse(
Format::Msbuild,
" Determining projects to restore...\n\
C:\\Users\\Jane Doe\\App\\Program.cs(12,5): error CS1002: ; expected [C:\\Users\\Jane Doe\\App\\App.csproj]\n\
/p/App/Models/User.cs(3,8,3,12): warning CS0168: The variable 'e' is declared but never used [/p/App/App.csproj]\n\
/p/App/App.csproj : error NU1101: Unable to find package Foo. [/p/App/App.csproj]\n\
MSBUILD : error MSB1009: Project file does not exist.\n\
Build FAILED.\n",
);
assert_eq!(out.len(), 4, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:\\Users\\Jane Doe\\App\\Program.cs", 12, Some(5)));
assert_eq!((out[0].code.as_deref(), out[0].text.as_str()), (Some("CS1002"), "; expected"));
assert_eq!(pos(&out[1]), (Severity::Warning, "/p/App/Models/User.cs", 3, Some(8)));
assert_eq!((out[1].end_line, out[1].end_col), (Some(3), Some(12)));
assert_eq!((out[2].path.as_deref(), out[2].line), (Some("/p/App/App.csproj"), None));
assert_eq!(out[2].text, "Unable to find package Foo.");
assert_eq!((out[3].path.as_deref(), out[3].code.as_deref()), (None, Some("MSB1009")));
}
#[test]
fn tsc_plain_and_pretty() {
let out = parse(
-4
Переглянути файл
@@ -118,10 +118,6 @@ pub enum IdeEvent {
LanguageServer {
server: ServerId,
name: String,
/// What the user sees as the server when another program serves in its place.
#[serde(default)]
#[specta(optional)]
label: Option<String>,
status: ServerStatus,
message: Option<String>,
},
+1 -9
Переглянути файл
@@ -1,10 +1,8 @@
//! JSON with comments (`devcontainer.json`, `deno.jsonc`, `tsconfig.json`): turned
//! into plain JSON for `serde_json`.
/// Strips `//` and `/* */` comments, trailing commas and a leading byte order mark (Visual
/// Studio writes one into `launchSettings.json`) so JSONC parses as JSON.
/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON.
pub fn strip(src: &str) -> String {
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let mut in_str = false;
@@ -89,10 +87,4 @@ mod tests {
assert_eq!(v["url"], "https://x.dev//y /* no */");
assert_eq!(v["q"], "say \"hi\",");
}
#[test]
fn drops_a_byte_order_mark() {
let v: serde_json::Value = serde_json::from_str(&strip("\u{feff}{ \"a\": 1 }")).unwrap();
assert_eq!(v["a"], 1);
}
}
+1 -23
Переглянути файл
@@ -1092,8 +1092,7 @@ impl Default for ExternalFormatterSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PythonSettings {
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`);
/// `target:<run-target-id>` = the Python of a Docker or Compose run target.
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`).
pub interpreter: String,
/// Activate the interpreter's virtualenv in new terminal sessions.
pub activate_virtualenv: bool,
@@ -1122,17 +1121,6 @@ impl Default for PythonSettings {
}
}
impl PythonSettings {
/// What `interpreter` starts with when the project interpreter is a run target's.
pub const TARGET_PREFIX: &str = "target:";
/// The run target whose Python is the project interpreter (`target:<id>`), if one is.
pub fn run_target(&self) -> Option<&str> {
let id = self.interpreter.trim().strip_prefix(Self::TARGET_PREFIX)?.trim();
(!id.is_empty()).then_some(id)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PhpSettings {
@@ -1554,16 +1542,6 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn python_interpreter_may_name_a_run_target() {
let python = |interpreter: &str| PythonSettings { interpreter: interpreter.into(), ..Default::default() };
assert_eq!(python("target:web").run_target(), Some("web"));
assert_eq!(python(" target: web ").run_target(), Some("web"));
for local in ["", "target:", "/usr/bin/python3", ".venv/bin/python", "targets/python"] {
assert_eq!(python(local).run_target(), None, "{local}");
}
}
#[test]
fn removed_one_themes_read_as_islands_themes() {
let settings: Settings = serde_json::from_str(
-3
Переглянути файл
@@ -260,8 +260,6 @@ pub struct DebuggerSettings {
pub show_on_breakpoint: bool,
pub focus_app_on_breakpoint: bool,
pub hide_on_termination: bool,
/// A breakpoint or exception hit brings the Debug tool window's Threads & Variables tab to the front.
pub variables_on_breakpoint: bool,
pub breakpoints_any_file: bool,
/// While suspended, hovering the start of a line shows a Run to Cursor button.
pub run_to_cursor_inlay: bool,
@@ -287,7 +285,6 @@ impl Default for DebuggerSettings {
show_on_breakpoint: true,
focus_app_on_breakpoint: false,
hide_on_termination: false,
variables_on_breakpoint: true,
breakpoints_any_file: false,
run_to_cursor_inlay: true,
inline_values: true,
-2
Переглянути файл
@@ -7,8 +7,6 @@ license.workspace = true
[dependencies]
futures.workspace = true
tokio-tungstenite.workspace = true
base64.workspace = true
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
-528
Переглянути файл
@@ -1,528 +0,0 @@
//! Replacing the responses a debugged page receives. js-debug hands out a WebSocket that
//! speaks the browser's own protocol (`requestCDPProxy`); over it the browser is told to hold
//! the requests whose URL matches a rule (`Fetch.enable`) and each held request is answered
//! here, with the rule's response or by letting it through.
//!
//! A held request stays held until it is answered, so interception is always switched off
//! (`Fetch.disable`) before the connection is given up: a page whose requests nobody answers hangs.
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT, js_debug};
use base64::Engine;
use futures::{SinkExt, StreamExt};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::HashMap;
use tokio::sync::{oneshot, watch};
use tokio_tungstenite::tungstenite::{Message, http::StatusCode};
/// A response given in place of the server's to every request whose URL contains `url_contains`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsResponseRule {
pub url_contains: String,
pub status: u16,
pub content_type: String,
pub body: String,
}
/// The browser's URL pattern (`*` any text, `?` one character, `\` escapes) for "contains `part`".
pub(crate) fn url_pattern(part: &str) -> String {
let mut pattern = String::with_capacity(part.len() + 2);
pattern.push('*');
for c in part.chars() {
if matches!(c, '*' | '?' | '\\') {
pattern.push('\\');
}
pattern.push(c);
}
pattern.push('*');
pattern
}
pub(crate) fn rule_for<'a>(rules: &'a [JsResponseRule], url: &str) -> Option<&'a JsResponseRule> {
rules.iter().find(|rule| url.contains(&rule.url_contains))
}
/// What to tell the browser about a request it holds: the first matching rule's response, or
/// to send the request on. Every held request gets one of the two.
pub(crate) fn answer(held: &Value, rules: &[JsResponseRule]) -> (&'static str, Value) {
let request_id = held.get("requestId").and_then(Value::as_str).unwrap_or_default();
let url = held.pointer("/request/url").and_then(Value::as_str).unwrap_or_default();
let Some(rule) = rule_for(rules, url) else {
return ("Fetch.continueRequest", json!({ "requestId": request_id }));
};
let header = |name: &str, value: &str| json!({ "name": name, "value": value });
// A page on another origin only reads a response that names its origin. The server would
// say so; the response given in its place has to.
let mut cross_origin = Vec::new();
if let Some(origin) = request_header(held, "Origin") {
cross_origin.push(header("Access-Control-Allow-Origin", origin));
cross_origin.push(header("Access-Control-Allow-Credentials", "true"));
}
let method = held.pointer("/request/method").and_then(Value::as_str).unwrap_or_default();
// The browser's question before such a request: allowed here, the server may not exist.
if method == "OPTIONS"
&& let Some(asked) = request_header(held, "Access-Control-Request-Method")
{
cross_origin.push(header("Access-Control-Allow-Methods", asked));
if let Some(asked) = request_header(held, "Access-Control-Request-Headers") {
cross_origin.push(header("Access-Control-Allow-Headers", asked));
}
let allowed = json!({
"requestId": request_id,
"responseCode": 204,
"responsePhrase": phrase(204),
"responseHeaders": cross_origin,
});
return ("Fetch.fulfillRequest", allowed);
}
let mut headers = vec![header("Content-Type", &rule.content_type)];
headers.extend(cross_origin);
let replaced = json!({
"requestId": request_id,
"responseCode": rule.status,
"responsePhrase": phrase(rule.status),
"responseHeaders": headers,
"body": base64::engine::general_purpose::STANDARD.encode(rule.body.as_bytes()),
});
("Fetch.fulfillRequest", replaced)
}
/// A header of the held request; the browser reports names as the page wrote them.
fn request_header<'a>(held: &'a Value, name: &str) -> Option<&'a str> {
held.pointer("/request/headers")?.as_object()?.iter().find(|(key, _)| key.eq_ignore_ascii_case(name))?.1.as_str()
}
/// The browser refuses a response without a phrase when it has none of its own for the status.
fn phrase(status: u16) -> &'static str {
StatusCode::from_u16(status).ok().and_then(|status| status.canonical_reason()).unwrap_or("Unknown")
}
fn interception(rules: &[JsResponseRule]) -> Value {
let patterns: Vec<Value> = rules
.iter()
.map(|rule| json!({ "urlPattern": url_pattern(&rule.url_contains), "requestStage": "Request" }))
.collect();
json!({ "patterns": patterns })
}
/// Applies `rules` to the page behind js-debug's CDP proxy at `ws_url` until their sender is
/// dropped (the session is gone) or the proxy closes the connection. The browser holds
/// requests only while there are rules. `ready` hears whether the proxy reports held
/// requests at all; nothing is held before it has agreed to.
pub(crate) async fn serve(
ws_url: String,
mut rules: watch::Receiver<Vec<JsResponseRule>>,
ready: oneshot::Sender<Result<(), String>>,
) {
let socket = match tokio_tungstenite::connect_async(ws_url.as_str()).await {
Ok((socket, _)) => socket,
Err(e) => {
let _ = ready.send(Err(e.to_string()));
return;
}
};
let (mut write, mut read) = socket.split();
let mut last_id = 0u64;
let mut call = |method: &str, params: Value| {
last_id += 1;
(last_id, Message::text(json!({ "id": last_id, "method": method, "params": params }).to_string()))
};
let (subscription, subscribe) = call("JsDebug.subscribe", json!({ "events": ["Fetch.requestPaused"] }));
let subscribed = tokio::time::timeout(REQUEST_TIMEOUT, async {
write.send(subscribe).await.map_err(|e| e.to_string())?;
loop {
let reply = read.next().await.ok_or("the connection closed")?.map_err(|e| e.to_string())?;
let Some(reply) = reply.to_text().ok().and_then(|text| serde_json::from_str::<Value>(text).ok()) else {
continue;
};
if reply.get("id").and_then(Value::as_u64) != Some(subscription) {
continue;
}
break match reply.get("error") {
Some(error) => Err(error.get("message").and_then(Value::as_str).unwrap_or("refused").to_owned()),
None => Ok(()),
};
}
})
.await
.unwrap_or_else(|_| Err("no answer".to_owned()));
if let Err(why) = subscribed {
let _ = ready.send(Err(why));
let _ = write.close().await;
return;
}
let _ = ready.send(Ok(()));
let mut current = rules.borrow_and_update().clone();
let mut holding = !current.is_empty();
if holding && write.send(call("Fetch.enable", interception(&current)).1).await.is_err() {
return;
}
// The request each sent replacement is for, by command id, until the browser has taken it.
let mut replacing: HashMap<u64, String> = HashMap::new();
loop {
tokio::select! {
changed = rules.changed() => {
if changed.is_err() {
break;
}
current = rules.borrow_and_update().clone();
let (_, command) = match (current.is_empty(), holding) {
(true, false) => continue,
(true, true) => call("Fetch.disable", json!({})),
(false, _) => call("Fetch.enable", interception(&current)),
};
holding = !current.is_empty();
if write.send(command).await.is_err() {
return;
}
}
message = read.next() => {
// The proxy closed (the page or the session ended): nothing is held any more.
let Some(Ok(message)) = message else { return };
let Ok(text) = message.to_text() else { continue };
let Ok(message) = serde_json::from_str::<Value>(text) else { continue };
if let Some(id) = message.get("id").and_then(Value::as_u64) {
// The browser did not take the replacement: let the request through, or
// it stays held and the page waits for it.
if let Some(request_id) = replacing.remove(&id)
&& message.get("error").is_some()
{
let (_, go_on) = call("Fetch.continueRequest", json!({ "requestId": request_id }));
if write.send(go_on).await.is_err() {
return;
}
}
continue;
}
if message.get("method").and_then(Value::as_str) != Some("Fetch.requestPaused") {
continue;
}
let Some(held) = message.get("params") else { continue };
let Some(request_id) = held.get("requestId").and_then(Value::as_str) else { continue };
let (method, params) = answer(held, &current);
let (id, command) = call(method, params);
if method == "Fetch.fulfillRequest" {
replacing.insert(id, request_id.to_owned());
}
if write.send(command).await.is_err() {
return;
}
}
}
}
if holding {
let _ = write.send(call("Fetch.disable", json!({})).1).await;
}
let _ = write.close().await;
}
impl DebugManager {
/// Makes `rules` the page's response rules; none switches the replacing off. The first
/// rules of a session open the connection to js-debug's CDP proxy, later ones go over it.
/// An error means the rules are not applied.
pub async fn js_set_response_overrides(&self, s: DebugSessionId, rules: Vec<JsResponseRule>) -> IdeResult<()> {
let session = self.session(s)?;
if !js_debug::is_page(&session) {
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
}
// Held to the end: two callers at once would each open a connection.
let mut applied = session.js_overrides.lock().await;
if applied.as_ref().is_some_and(|sender| sender.send(rules.clone()).is_ok()) {
return Ok(());
}
// No connection yet, or the proxy closed it.
applied.take();
if rules.is_empty() {
return Ok(());
}
let refused =
|why: String| IdeError::unavailable(format!("the JavaScript debugger has no browser connection: {why}"));
if !js_debug::launched(&session).await {
return Err(refused("the page's debug session did not finish starting".into()));
}
let proxy = session
.conn
.request_timeout("requestCDPProxy", json!({}), REQUEST_TIMEOUT)
.await
.map_err(|e| refused(e.to_string()))?;
let (Some(host), Some(port)) =
(proxy.get("host").and_then(Value::as_str), proxy.get("port").and_then(Value::as_u64))
else {
return Err(refused("no address".into()));
};
let path = proxy.get("path").and_then(Value::as_str).unwrap_or_default();
let (sender, receiver) = watch::channel(rules);
let (ready, is_ready) = oneshot::channel();
tokio::spawn(serve(format!("ws://{host}:{port}{path}"), receiver, ready));
match is_ready.await {
Ok(Ok(())) => {
*applied = Some(sender);
Ok(())
}
Ok(Err(why)) => Err(refused(why)),
Err(_) => Err(refused("no answer".into())),
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use futures::{SinkExt, StreamExt};
use serde_json::json;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
pub(crate) fn rule(url_contains: &str, status: u16, body: &str) -> JsResponseRule {
JsResponseRule {
url_contains: url_contains.into(),
status,
content_type: "application/json".into(),
body: body.into(),
}
}
#[test]
fn a_part_of_a_url_becomes_a_pattern_that_matches_it_anywhere() {
assert_eq!(url_pattern("api/items"), "*api/items*");
// The pattern's own wildcards and escape character are taken literally.
assert_eq!(url_pattern(r"a*b?c\d"), r"*a\*b\?c\\d*");
}
#[test]
fn the_first_rule_whose_text_is_in_the_url_answers() {
let rules = [rule("api/items", 201, "{\"a\":\"é\"}"), rule("api", 500, "later")];
assert_eq!(rule_for(&rules, "http://h/api/items?x=1").map(|r| r.status), Some(201));
assert_eq!(rule_for(&rules, "http://h/api/users").map(|r| r.status), Some(500));
assert!(rule_for(&rules, "http://h/logo.png").is_none());
let (command, params) = answer(&held("7", "http://h/api/items")["params"], &rules);
assert_eq!(command, "Fetch.fulfillRequest");
assert_eq!(
params,
json!({
"requestId": "7",
"responseCode": 201,
"responsePhrase": "Created",
"responseHeaders": [{ "name": "Content-Type", "value": "application/json" }],
// base64 of the UTF-8 text `{"a":"é"}`
"body": "eyJhIjoiw6kifQ==",
})
);
// A request the browser held that no rule is for goes on to the network.
assert_eq!(
answer(&held("8", "http://h/logo.png")["params"], &rules),
("Fetch.continueRequest", json!({ "requestId": "8" }))
);
}
#[test]
fn a_status_the_browser_has_no_phrase_for_is_given_one() {
// The browser refuses a response whose status it cannot name.
let (_, params) = answer(&held("1", "http://h/api")["params"], &[rule("api", 419, "")]);
assert_eq!((&params["responseCode"], &params["responsePhrase"]), (&json!(419), &json!("Unknown")));
}
fn header<'a>(params: &'a Value, name: &str) -> Option<&'a str> {
params["responseHeaders"].as_array()?.iter().find(|h| h["name"] == name)?["value"].as_str()
}
#[test]
fn a_replaced_response_passes_the_pages_cross_origin_check() {
let rules = [rule("api/items", 200, "[]")];
let from_page = |method: &str, headers: Value| json!({ "requestId": "3", "request": { "url": "http://localhost:8080/api/items", "method": method, "headers": headers } });
// A page on another origin: the response names that origin, as the real server would.
let (_, params) = answer(&from_page("GET", json!({ "origin": "http://localhost:5173" })), &rules);
assert_eq!(header(&params, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
assert_eq!(header(&params, "Access-Control-Allow-Credentials"), Some("true"));
assert_eq!(params["body"], "W10=");
// The browser's question before such a request is answered too: the server may not exist.
let preflight = from_page(
"OPTIONS",
json!({
"Origin": "http://localhost:5173",
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "content-type,x-token",
}),
);
let (command, params) = answer(&preflight, &rules);
assert_eq!((command, &params["responseCode"]), ("Fetch.fulfillRequest", &json!(204)));
assert_eq!(header(&params, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
assert_eq!(header(&params, "Access-Control-Allow-Methods"), Some("PUT"));
assert_eq!(header(&params, "Access-Control-Allow-Headers"), Some("content-type,x-token"));
assert_eq!(params.get("body"), None);
// Same origin: nothing but the rule's own header.
let (_, params) = answer(&from_page("GET", json!({})), &rules);
assert_eq!(params["responseHeaders"], json!([{ "name": "Content-Type", "value": "application/json" }]));
}
/// A stand-in for js-debug's CDP proxy: what it receives, and a way to send it events.
pub(crate) async fn proxy() -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
proxy_refusing(|_| false).await
}
/// A proxy that answers the commands `refused` picks with an error, like the real one does
/// for what it or the browser does not accept.
pub(crate) async fn proxy_refusing(
refused: fn(&Value) -> bool,
) -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
let (received_tx, received) = mpsc::unbounded_channel();
let (events, mut events_rx) = mpsc::unbounded_channel::<Value>();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (mut write, mut read) = tokio_tungstenite::accept_async(stream).await.unwrap().split();
loop {
tokio::select! {
message = read.next() => {
let Some(Ok(message)) = message else { break };
let Ok(text) = message.to_text() else { continue };
let Ok(request) = serde_json::from_str::<Value>(text) else { continue };
let reply = if refused(&request) {
json!({ "id": request["id"], "error": { "code": -32602, "message": "refused" } })
} else {
json!({ "id": request["id"], "result": {} })
};
let _ = write.send(Message::text(reply.to_string())).await;
let _ = received_tx.send(request);
}
Some(event) = events_rx.recv() => {
let _ = write.send(Message::text(event.to_string())).await;
}
}
}
});
(url, received, events)
}
pub(crate) async fn next(received: &mut mpsc::UnboundedReceiver<Value>) -> (String, Value) {
let request = tokio::time::timeout(std::time::Duration::from_secs(5), received.recv())
.await
.expect("the proxy got a request in time")
.expect("the connection is open");
(request["method"].as_str().unwrap().to_owned(), request["params"].clone())
}
/// The browser's event for a request it holds.
fn held(request_id: &str, url: &str) -> Value {
json!({ "method": "Fetch.requestPaused", "params": { "requestId": request_id, "request": { "url": url } } })
}
type Serving = (tokio::task::JoinHandle<()>, oneshot::Receiver<Result<(), String>>);
fn serving(url: String, rules: watch::Receiver<Vec<JsResponseRule>>) -> Serving {
let (ready, is_ready) = oneshot::channel();
(tokio::spawn(serve(url, rules, ready)), is_ready)
}
async fn ended(task: tokio::task::JoinHandle<()>) {
tokio::time::timeout(std::time::Duration::from_secs(5), task).await.unwrap().unwrap();
}
#[tokio::test]
async fn held_requests_are_answered_and_interception_ends_before_the_connection() {
let (url, mut received, events) = proxy().await;
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api/items", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert_eq!(ready.await.unwrap(), Ok(()));
assert_eq!(
next(&mut received).await,
("JsDebug.subscribe".into(), json!({ "events": ["Fetch.requestPaused"] }))
);
assert_eq!(
next(&mut received).await,
(
"Fetch.enable".into(),
json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] })
)
);
events.send(held("1", "http://h/api/items")).unwrap();
let (command, params) = next(&mut received).await;
assert_eq!(
(command.as_str(), &params["requestId"], &params["responseCode"]),
("Fetch.fulfillRequest", &json!("1"), &json!(200))
);
events.send(held("2", "http://h/other")).unwrap();
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "2" })));
// New rules replace the browser's patterns.
rules.send(vec![rule("api/users", 404, "{}")]).unwrap();
assert_eq!(
next(&mut received).await,
(
"Fetch.enable".into(),
json!({ "patterns": [{ "urlPattern": "*api/users*", "requestStage": "Request" }] })
)
);
// No rules left: the browser stops holding requests. The connection stays for the
// rules that come next, and goes with the session.
rules.send(Vec::new()).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
rules.send(vec![rule("api/items", 200, "[]")]).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.enable");
rules.send(Vec::new()).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
drop(rules);
ended(serving).await;
// Already off: not switched off a second time.
assert!(received.try_recv().is_err());
}
#[tokio::test]
async fn a_session_that_goes_away_switches_interception_off() {
let (url, mut received, _events) = proxy().await;
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, _ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(next(&mut received).await.0, "Fetch.enable");
drop(rules);
assert_eq!(next(&mut received).await.0, "Fetch.disable");
ended(serving).await;
}
#[tokio::test]
async fn a_response_the_browser_refuses_lets_the_request_through() {
let (url, mut received, events) = proxy_refusing(|request| request["method"] == "Fetch.fulfillRequest").await;
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (_serving, _ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(next(&mut received).await.0, "Fetch.enable");
events.send(held("9", "http://h/api")).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.fulfillRequest");
// Unanswered, the request would hang the page.
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "9" })));
}
#[tokio::test]
async fn a_proxy_that_does_not_report_held_requests_is_not_asked_to_hold_any() {
let (url, mut received, _events) = proxy_refusing(|request| request["method"] == "JsDebug.subscribe").await;
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(ready.await.unwrap(), Err("refused".to_owned()));
ended(serving).await;
assert!(received.try_recv().is_err());
}
#[tokio::test]
async fn no_connection_to_the_proxy_is_reported() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
drop(listener);
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert!(ready.await.unwrap().is_err());
ended(serving).await;
}
}
+1 -41
Переглянути файл
@@ -11,8 +11,6 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
pub(crate) const ADAPTER: &str = "js-debug";
@@ -27,7 +25,7 @@ pub(crate) fn is_page(session: &Session) -> bool {
/// Waits until the adapter answered the `launch` of `session`. js-debug sets a page's session
/// up between `configurationDone` and that answer; a request of its own that arrives then is
/// answered and has no effect.
pub(crate) async fn launched(session: &Session) -> bool {
async fn launched(session: &Session) -> bool {
let mut launched = session.launched.subscribe();
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
matches!(answered, Ok(Ok(_)))
@@ -61,44 +59,6 @@ impl DebugManager {
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
Ok(())
}
/// Starts recording the page's CPU profile; [`Self::js_stop_profile`] ends it.
pub async fn js_start_profile(&self, s: DebugSessionId) -> IdeResult<()> {
self.js_page_request(s, "startProfile", json!({ "type": "cpu" })).await?;
Ok(())
}
/// Ends the recording and returns a new directory holding it as `page.cpuprofile`
/// (what `ide-profiler` reads a directory of).
pub async fn js_stop_profile(&self, s: DebugSessionId) -> IdeResult<PathBuf> {
let session = self.session(s)?;
// js-debug names the file it records into when the recording starts.
let Some(recorded) = session.js_profile.lock().take() else {
return Err(IdeError::invalid("no CPU profile is being recorded"));
};
self.js_page_request(s, "stopProfile", json!({})).await?;
static HANDED_OVER: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"openidea-js-profile-{}-{}-{}",
std::process::id(),
s.0,
HANDED_OVER.fetch_add(1, Ordering::Relaxed)
));
let copied = tokio::task::spawn_blocking({
let dir = dir.clone();
move || {
std::fs::create_dir_all(&dir)?;
std::fs::copy(&recorded, dir.join("page.cpuprofile"))?;
// The adapter's own copy is in the system's temporary directory: no use keeping two.
let _ = std::fs::remove_file(&recorded);
Ok::<_, std::io::Error>(())
}
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
copied.map_err(|e| IdeError::unavailable(format!("the recorded CPU profile cannot be read: {e}")))?;
Ok(dir)
}
}
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
-2
Переглянути файл
@@ -13,7 +13,6 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod js_cdp;
mod js_debug;
mod jvm_memory;
pub mod kotlin;
@@ -25,7 +24,6 @@ use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use js_cdp::JsResponseRule;
pub use js_debug::JsResponseBody;
pub use jvm_memory::*;
use parking_lot::Mutex;
+8 -70
Переглянути файл
@@ -94,11 +94,6 @@ pub(crate) struct Session {
/// The `type` of the launch or attach request (js-debug's `pwa-node`, `pwa-chrome`); a child
/// session's comes from the configuration the adapter started it with.
pub launch_type: std::sync::OnceLock<String>,
/// The response rules of a page once a connection applies them (`js_cdp::serve`).
/// Dropped with the session, which switches the browser's interception off.
pub js_overrides: tokio::sync::Mutex<Option<tokio::sync::watch::Sender<Vec<crate::JsResponseRule>>>>,
/// The file js-debug records the page's CPU profile into, while one is being recorded.
pub js_profile: parking_lot::Mutex<Option<std::path::PathBuf>>,
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
/// One inspector request per session at a time.
pub inspect_lock: tokio::sync::Mutex<()>,
@@ -260,8 +255,6 @@ impl Session {
progress: Mutex::new(HashMap::new()),
jdwp_link: parking_lot::Mutex::new(None),
launch_type: std::sync::OnceLock::new(),
js_overrides: tokio::sync::Mutex::new(None),
js_profile: parking_lot::Mutex::new(None),
jvm_inspector: parking_lot::Mutex::new(None),
inspect_lock: tokio::sync::Mutex::default(),
ended: ide_core::CancellationToken::new(),
@@ -481,7 +474,10 @@ impl Session {
let _ = c.start_kill();
}
if let Some(mut program) = self.program.lock().take() {
program.kill();
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
}
}
}
@@ -578,7 +574,7 @@ fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(ProgramTerminal::process_id));
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
groups.retain(|g| *g > 0);
groups
}
@@ -849,10 +845,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
s.emit(DebugEvent::JsNetwork { event });
}
}
// js-debug, after `startProfile`: where it records the profile.
"profileStarted" => {
*s.js_profile.lock() = str_("file").map(std::path::PathBuf::from);
}
_ => tracing::trace!("dap: ignored event {event}"),
}
}
@@ -882,10 +874,7 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: ide_core::input::InputWriter,
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
pid: Option<u32>,
/// Set once the program has exited and been reaped.
exited: Arc<AtomicBool>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
@@ -893,28 +882,6 @@ impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write(text.as_bytes())
}
/// The program's process id while it runs.
pub(crate) fn process_id(&self) -> Option<u32> {
if self.exited.load(Ordering::Acquire) { None } else { self.pid }
}
pub(crate) fn kill(&mut self) {
// After the reap the pid may belong to another process.
if !self.exited.load(Ordering::Acquire) {
let _ = self.killer.kill();
}
}
}
/// Reaps the program as soon as it exits. Until then it stays a zombie that `kill(pid, 0)` still
/// finds: js-debug polls the `processId` it was given and keeps the session (and its terminal)
/// open while that process looks alive, and Stop would wait for it as for a running program.
fn reap_on_exit(mut child: Box<dyn portable_pty::Child + Send + Sync>, exited: Arc<AtomicBool>) {
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = child.wait();
exited.store(true, Ordering::Release);
});
}
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
@@ -1001,12 +968,8 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
#[cfg(unix)]
wait_readable,
);
let killer = child.clone_killer();
let exited = Arc::new(AtomicBool::new(false));
reap_on_exit(child, exited.clone());
let program = ProgramTerminal { writer, killer, pid, exited, _master: pair.master };
if let Some(mut previous) = s.program.lock().replace(program) {
previous.kill();
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
Ok(json!({ "processId": pid }))
@@ -1375,29 +1338,4 @@ mod tests {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
/// js-debug polls the `runInTerminal` pid with `kill(pid, 0)`; an unreaped zombie answers it as
/// a live process and keeps the session open after the program ends.
#[cfg(unix)]
#[test]
fn a_terminal_program_is_reaped_when_it_exits() {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let pair =
native_pty_system().openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 }).unwrap();
let mut cmd = CommandBuilder::new("/bin/sh");
cmd.args(["-c", "exit 0"]);
let child = pair.slave.spawn_command(cmd).unwrap();
let pid = child.process_id().unwrap() as libc::pid_t;
let exited = Arc::new(AtomicBool::new(false));
super::reap_on_exit(child, exited.clone());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !exited.load(Ordering::Acquire) {
assert!(std::time::Instant::now() < deadline, "program was not reaped");
std::thread::sleep(std::time::Duration::from_millis(10));
}
// SAFETY: signal 0 only checks whether the process exists.
assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
}
}
-136
Переглянути файл
@@ -1104,142 +1104,6 @@ async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
}
/// Where the fake adapter says its CDP proxy listens (`requestCDPProxy`).
static CDP_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
fn proxy_adapter(msg: &Value) -> Option<CustomReply> {
let port = CDP_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
(msg["command"] == "requestCDPProxy")
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
}
#[tokio::test]
async fn response_overrides_reach_the_page_through_the_adapters_proxy() {
use crate::js_cdp::tests::{next, proxy, rule};
let (url, mut received, _events) = proxy().await;
let port: u16 = url.trim_start_matches("ws://127.0.0.1:").trim_end_matches("/session").parse().unwrap();
CDP_PROXY_PORT.store(port, std::sync::atomic::Ordering::Relaxed);
let h = harness(Script {
start_debugging: true,
custom: Some(proxy_adapter),
child_launch_delay: Duration::from_millis(150),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Rules that are there as the page starts wait until its session takes requests.
tokio::time::sleep(Duration::from_millis(50)).await;
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "the proxy was asked for before the page took requests");
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(
next(&mut received).await,
("Fetch.enable".into(), json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] }))
);
// Changed rules go over the connection that is open: the proxy is asked for once.
h.manager.js_set_response_overrides(page, vec![rule("api/users", 200, "[]")]).await.unwrap();
assert_eq!(next(&mut received).await.1["patterns"][0]["urlPattern"], "*api/users*");
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
// A rule switched back on.
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.enable");
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
let not_a_page = h.manager.js_set_response_overrides(browser, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
}
#[tokio::test]
async fn response_overrides_are_unavailable_without_the_adapters_proxy() {
use crate::js_cdp::tests::rule;
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
let refused = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
// Nothing to switch off is not an error.
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
}
/// A port nothing listens on, which the fake adapter gives as its CDP proxy.
static DEAD_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
fn dead_proxy_adapter(msg: &Value) -> Option<CustomReply> {
let port = DEAD_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
(msg["command"] == "requestCDPProxy")
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
}
#[tokio::test]
async fn response_overrides_that_cannot_reach_the_browser_are_an_error() {
use crate::js_cdp::tests::rule;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
DEAD_PROXY_PORT.store(listener.local_addr().unwrap().port(), std::sync::atomic::Ordering::Relaxed);
drop(listener);
let h = harness(Script { start_debugging: true, custom: Some(dead_proxy_adapter), ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
let unreachable = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(unreachable.kind, ide_core::error::ErrorKind::Unavailable);
// The next rules try again instead of going to a connection that never was.
let again = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(again.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(requests(&h, "requestCDPProxy").len(), 2);
}
/// The file the fake adapter says it records a CPU profile into (`profileStarted`).
static PROFILE_FILE: parking_lot::Mutex<String> = parking_lot::Mutex::new(String::new());
fn profiling_adapter(msg: &Value) -> Option<CustomReply> {
match msg["command"].as_str()? {
"startProfile" => {
Some((json!({}), vec![("profileStarted", json!({ "file": *PROFILE_FILE.lock(), "type": "cpu" }))]))
}
"stopProfile" => Some((json!({}), vec![("profilerStateUpdate", json!({ "label": "", "running": false }))])),
_ => None,
}
}
#[tokio::test]
async fn a_pages_cpu_profile_is_handed_over_as_a_cpuprofile_file() {
let recorded = std::env::temp_dir().join(format!("openidea-dap-profile-{}", std::process::id()));
std::fs::write(&recorded, r#"{"nodes":[],"samples":[]}"#).unwrap();
*PROFILE_FILE.lock() = recorded.to_string_lossy().into_owned();
let h = harness(Script { start_debugging: true, custom: Some(profiling_adapter), ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Nothing is being recorded yet.
let idle = h.manager.js_stop_profile(page).await.unwrap_err();
assert_eq!(idle.kind, ide_core::error::ErrorKind::InvalidInput);
h.manager.js_start_profile(page).await.unwrap();
assert_eq!(requests(&h, "startProfile"), [json!({ "type": "cpu" })]);
let dir = h.manager.js_stop_profile(page).await.unwrap();
assert_eq!(requests(&h, "stopProfile").len(), 1);
let files: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(files, ["page.cpuprofile"]);
assert_eq!(std::fs::read_to_string(dir.join("page.cpuprofile")).unwrap(), r#"{"nodes":[],"samples":[]}"#);
// One recording is handed over once.
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::InvalidInput);
// The adapter's file isn't there when it is asked for (the hand-over above took the first one).
assert!(!recorded.exists());
h.manager.js_start_profile(page).await.unwrap();
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(h.manager.js_start_profile(browser).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
-18
Переглянути файл
@@ -542,24 +542,6 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
/// Runs the docker CLI with `args` in `dir` and returns its standard output; a non-zero
/// exit is an error with docker's message.
pub fn cli(
ep: &DockerEndpoint,
dir: Option<&Path>,
args: &[String],
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> IdeResult<String> {
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let mut cmd = command(ep, &args)?;
if let Some(dir) = dir {
cmd.current_dir(dir);
}
let out = process::run(cmd, timeout, cancel)?;
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
#[cfg(test)]
mod local_tests {
use super::*;
-2
Переглянути файл
@@ -66,7 +66,6 @@ pub struct Host {
pub mcp_requests: mcp::Requests,
/// Commands agents run in the Run tool window (`run_command`).
pub mcp_agent_runs: mcp::AgentRuns,
pub(crate) agent_catalogs: Arc<ide_agent::AgentCatalogs>,
pub trust: trust::TrustStore,
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf,
@@ -153,7 +152,6 @@ impl Host {
mcp_reports: Default::default(),
mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
agent_catalogs: Arc::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
+2 -11
Переглянути файл
@@ -7,8 +7,8 @@ use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentModelCatalog, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -28,12 +28,6 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
Ok(providers)
}
/// Refreshes the selected agent's model catalog without sending a prompt.
pub fn refresh_models(host: &Host, window: &str, provider: String, force: bool) -> IdeResult<AgentModelCatalog> {
host.require_trust(window)?;
host.project(window)?.agents.refresh_models(&provider, force)
}
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
pub fn set_command(host: &Host, window: &str, id: String, command: Option<String>) -> IdeResult<()> {
@@ -563,9 +557,6 @@ pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
d.add("agent_refresh_models", |c| async move {
refresh_models(&c.host, &c.window, c.arg("provider")?, c.arg("force")?)
});
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) });
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
+6 -45
Переглянути файл
@@ -12,7 +12,6 @@ use ide_db::{
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
/// One statement of a console run.
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -180,7 +179,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
});
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
d.add("db_consoles", |c| async move { consoles(&c.host, &c.arg::<String>("id")?, c.arg("create")?) });
}
/// The data sources configured for the project (the tree's roots).
@@ -474,52 +472,11 @@ pub async fn apply_edits(
/// created on first use. Consoles are files of the project, so a remote
/// window resolves it on the host the project runs on.
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
let file = console_dir(host, id)?.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
/// The query consoles of data source `id`: the default `console.sql`, then
/// `console_1.sql`, `console_2.sql`… in order. `create` adds the next one first
/// (DataGrip's New › Query Console).
pub fn consoles(host: &Host, id: &str, create: bool) -> IdeResult<Vec<IpcPath>> {
let dir = console_dir(host, id)?;
let default = dir.join("console.sql");
let mut numbered: Vec<(u32, PathBuf)> = std::fs::read_dir(&dir)?
.filter_map(|e| {
let path = e.ok()?.path();
let n = path.file_name()?.to_str()?.strip_prefix("console_")?.strip_suffix(".sql")?.parse().ok()?;
Some((n, path))
})
.collect();
numbered.sort();
if create {
// The default console comes first; once it exists, the next number.
if !default.exists() {
std::fs::write(&default, "")?;
} else {
let n = numbered.last().map_or(1, |(n, _)| n + 1);
let file = dir.join(format!("console_{n}.sql"));
std::fs::write(&file, "")?;
numbered.push((n, file));
}
}
let mut out: Vec<IpcPath> = Vec::new();
if default.exists() {
out.push(default.into());
}
out.extend(numbered.into_iter().map(|(_, p)| p.into()));
Ok(out)
}
/// `<dataDir>/consoles/db/<id>`: `<id>` for the default database, `<id>.<database>` for the others.
fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
let (source, database) = ide_db::split_target(id);
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(IdeError::invalid("invalid data source id"));
}
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
let name = match database {
Some(db) => {
let safe: String =
@@ -530,5 +487,9 @@ fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
};
let dir = host.data_dir.join("consoles").join("db").join(&name);
std::fs::create_dir_all(&dir)?;
Ok(dir)
let file = dir.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
-32
Переглянути файл
@@ -382,15 +382,6 @@ pub async fn debug_js_custom_breakpoints(
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
}
pub async fn debug_js_response_overrides(
host: &Host,
_window: &str,
session: DebugSessionId,
rules: Vec<ide_dap::JsResponseRule>,
) -> IdeResult<()> {
host.debugger.js_set_response_overrides(session, rules).await
}
pub async fn debug_js_response_body(
host: &Host,
_window: &str,
@@ -400,18 +391,6 @@ pub async fn debug_js_response_body(
host.debugger.js_response_body(session, request_id).await
}
pub async fn debug_js_start_profile(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.js_start_profile(session).await
}
pub async fn debug_js_stop_profile(
host: &Host,
_window: &str,
session: DebugSessionId,
) -> IdeResult<ide_core::IpcPath> {
host.debugger.js_stop_profile(session).await.map(Into::into)
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
@@ -628,23 +607,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
});
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
d.add("debug_js_response_overrides", |c| async move {
debug_js_response_overrides(&c.host, &c.window, c.arg("session")?, c.arg("rules")?).await
});
d.add("debug_js_response_body", |c| async move {
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
});
d.add("debug_js_custom_breakpoints", |c| async move {
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
});
d.add(
"debug_js_start_profile",
|c| async move { debug_js_start_profile(&c.host, &c.window, c.arg("session")?).await },
);
d.add(
"debug_js_stop_profile",
|c| async move { debug_js_stop_profile(&c.host, &c.window, c.arg("session")?).await },
);
d.add("debug_set_function_breakpoints", |c| async move {
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
+1 -87
Переглянути файл
@@ -2,7 +2,7 @@ use super::doc::blocking;
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, LanguageId, Location, Pos};
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
@@ -22,23 +22,6 @@ pub struct WebGoto {
const MAX_WEB_TARGETS: usize = 200;
/// A Python class definition and the base names written in its source. The UI builds a
/// name → locations index from every class in the project (attached dependency roots
/// included) to compute ↑ overrides / ↓ overridden gutter markers without a language
/// server's type-hierarchy provider.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ClassInheritance {
/// Start of the class definition's selection range (0-based line, UTF-16 column).
pub path: IpcPath,
pub line: u32,
pub col: u32,
pub name: String,
/// Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
/// qualified name keeps only its last segment).
pub bases: Vec<String>,
}
/// The project root and its resource roots (served at `/`), for file references.
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
@@ -191,74 +174,6 @@ pub async fn nav_index_definitions(
.await
}
/// Every Python class in the project (attached dependency roots included) and its base
/// names. The index covers content roots, so this is the whole-project answer the UI uses
/// for inheritance gutter markers when no type-hierarchy provider exists.
pub async fn nav_inheritance(host: &Host, window: &str) -> IdeResult<Vec<ClassInheritance>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let python = LanguageId::new("python");
let files = project.index.files_with_extensions(&["py".into(), "pyi".into()], 20_000);
let mut cache = CLASS_BASES.lock();
let mut out = Vec::new();
for path in files {
// The open (possibly unsaved) buffer is keyed by its text, a file on disk by its
// size and modification time, so only changed files are parsed again.
let open = project.documents.text_of(&path);
let stamp = match &open {
Some(text) => Stamp::Text(text_hash(text)),
None => match std::fs::metadata(&path) {
Ok(m) => Stamp::Disk(m.len(), m.modified().ok()),
Err(_) => continue,
},
};
let classes = match cache.get(&path) {
Some((cached, classes)) if *cached == stamp => classes,
_ => {
let text = match open {
Some(text) => text,
None => match project.text_of(&path) {
Ok(text) => text,
Err(_) => continue,
},
};
let classes = syntax.class_bases(&python, &text);
&cache.entry(path.clone()).insert_entry((stamp, classes)).into_mut().1
}
};
out.extend(classes.iter().map(|class| ClassInheritance {
path: IpcPath::from(path.clone()),
line: class.line,
col: class.col,
name: class.name.clone(),
bases: class.bases.clone(),
}));
}
Ok(out)
})
.await
}
#[derive(Clone, PartialEq, Eq)]
enum Stamp {
Text(u64),
Disk(u64, Option<std::time::SystemTime>),
}
fn text_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
h.finish()
}
type ClassBasesCache = std::collections::HashMap<PathBuf, (Stamp, Vec<ide_language::ClassBases>)>;
/// Class bases per Python file, reused while the file is unchanged.
static CLASS_BASES: std::sync::LazyLock<parking_lot::Mutex<ClassBasesCache>> =
std::sync::LazyLock::new(Default::default);
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
@@ -334,7 +249,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("nav_index_definitions", |c| async move {
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("nav_inheritance", |c| async move { nav_inheritance(&c.host, &c.window).await });
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
-6
Переглянути файл
@@ -239,12 +239,6 @@ pub fn set_settings(
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// A run target chosen as the Python interpreter (or changed) needs a snapshot of its libraries.
if ["languages", "languages.python", "languages.python.interpreter", "runTargets"].contains(&key) {
for project in affected() {
crate::ops::sdk::container_python::ensure(host, &project);
}
}
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
+1 -1
Переглянути файл
@@ -508,7 +508,7 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
pub(crate) mod container;
mod container;
pub use commands::*;
#[cfg(test)]
+15 -26
Переглянути файл
@@ -33,11 +33,23 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
let root = root.to_path_buf();
let data_dir = host.data_dir.clone();
host.blocking(move || {
require_local(&endpoint, &target)?;
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
let docker = ide_docker::docker_executable().ok_or_else(|| {
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
})?;
let container_root = container_root(&endpoint, &root, &target)?;
let container_root = match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), &root)?
}
"" => default_container_root(&root),
folder => folder.to_owned(),
};
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
@@ -63,29 +75,6 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
.await
}
/// The project can only be bind-mounted from a daemon on this machine.
pub(crate) fn require_local(endpoint: &DockerEndpoint, target: &RunTarget) -> IdeResult<()> {
if !ide_docker::endpoint_is_local(endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
Ok(())
}
/// Where the project is in the target's containers.
pub(crate) fn container_root(endpoint: &DockerEndpoint, root: &Path, target: &RunTarget) -> IdeResult<String> {
Ok(match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(endpoint, root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), root)?
}
"" => default_container_root(root),
folder => folder.to_owned(),
})
}
/// What a debug run mounts and publishes.
struct Debugging {
port: u16,
@@ -115,7 +104,7 @@ fn free_port() -> IdeResult<u16> {
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
pub(crate) fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
if id.is_empty() {
return Ok(DockerEndpoint::Default);
+2 -12
Переглянути файл
@@ -10,9 +10,6 @@ use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
pub(crate) mod container_python;
pub use container_python::{ContainerPython, sdk_container_python, sdk_container_python_refresh};
fn join_err(e: impl std::fmt::Display) -> IdeError {
IdeError::internal(e.to_string())
}
@@ -235,17 +232,12 @@ pub async fn sdk_project_libraries(
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let cancel = host.begin_project_request(window, request);
let container = container_python::library_group(host, &root, &settings);
let libraries = {
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
tokio::task::spawn_blocking(move || {
if let Some(group) = &container {
let _ = on_groups.send(vec![group.clone()]);
}
let groups = ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
let _ = on_groups.send(groups);
});
container.into_iter().chain(groups).collect::<Vec<_>>()
})
})
};
let dependencies = {
@@ -385,8 +377,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add("sdk_container_python", |c| async move { sdk_container_python(&c.host, &c.window) });
d.add("sdk_container_python_refresh", |c| async move { sdk_container_python_refresh(&c.host, &c.window).await });
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
d.add(
"sdk_php_language_level",
-298
Переглянути файл
@@ -1,298 +0,0 @@
//! A Docker or Compose run target's Python as the project interpreter: snapshots of the
//! container's library folders for the language server (see `ide_sdk::container_python`).
//!
//! A snapshot runs the target's Python once in a new container (the image's entrypoint
//! and the service's dependencies skipped), then copies its `sys.path` folders out of
//! the stopped container with `docker cp`. Nothing is installed in the container.
use crate::Host;
use crate::project::Project;
use ide_core::event::NotifyLevel;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::settings::Settings;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, TaskId};
use ide_sdk::container_python::{self as snapshots, Snapshot};
use ide_sdk::python::PythonPackage;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
/// Building or pulling the image may come first.
const PROBE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
const COPY_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const QUICK: Duration = Duration::from_secs(30);
/// The project interpreter when it is a run target's.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ContainerPython {
/// The run target's id.
pub target: String,
/// Its name; empty when no run target has the id.
pub name: String,
/// The Python version of the snapshot in use; `None`: no snapshot yet.
pub version: Option<String>,
/// The image the snapshot was copied from.
pub image: Option<String>,
/// The copied library folders on this machine.
pub folders: Vec<IpcPath>,
/// Packages of the snapshot (read-only).
pub packages: Vec<PythonPackage>,
/// A snapshot is being taken.
pub updating: bool,
}
/// Where snapshots are kept, and the Docker connections the targets refer to.
#[derive(Clone)]
struct Dirs {
data: PathBuf,
/// `<local data>/container-python/`: `<project>/<target>/<image>/` and `neutral/`.
cache: PathBuf,
}
impl Dirs {
fn of(host: &Host) -> Self {
Self::new(&host.data_dir, &host.local_data_dir)
}
fn new(data_dir: &Path, local_data_dir: &Path) -> Self {
Self { data: data_dir.to_path_buf(), cache: local_data_dir.join("container-python") }
}
fn target(&self, root: &Path, target: &str) -> PathBuf {
snapshots::target_dir(&self.cache.join(crate::project::project_key(root)), target)
}
}
/// Target folders with a snapshot being taken.
static UPDATING: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(Default::default);
fn updating(dir: &Path) -> bool {
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).contains(dir)
}
/// The snapshot in use for the project's interpreter, if it is a run target's.
fn current(dirs: &Dirs, root: &Path, settings: &Settings) -> Option<(PathBuf, Snapshot)> {
let target = settings.languages.python.run_target()?;
snapshots::current(&dirs.target(root, target))
}
/// Language server configuration: basedpyright / pyright see the container's libraries.
pub(crate) fn configure_server(
cfg: &mut ide_language::LanguageServerConfig,
local_data_dir: &Path,
root: &Path,
settings: &Settings,
) {
if settings.languages.python.run_target().is_none() {
return;
}
let current = current(&Dirs::new(local_data_dir, local_data_dir), root, settings);
snapshots::configure_server(cfg, current.as_ref().map(|(d, s)| (d.as_path(), s)));
}
/// External Libraries: the snapshot's folders, named after the run target.
pub(crate) fn library_group(host: &Host, root: &Path, settings: &Settings) -> Option<ide_sdk::libraries::LibraryGroup> {
let target = settings.languages.python.run_target()?;
let (dir, snapshot) = current(&Dirs::of(host), root, settings)?;
let name = settings.run_targets.iter().find(|t| t.id == target).map_or(target, |t| t.name.as_str());
snapshots::library_group(name, &dir, &snapshot)
}
/// The project interpreter when it is a run target's, with its snapshot.
pub fn sdk_container_python(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
host.require_trust(window)?;
let project = host.project(window)?;
Ok(describe(host, &project.root, &project.settings.effective()))
}
fn describe(host: &Host, root: &Path, settings: &Settings) -> Option<ContainerPython> {
let target = settings.languages.python.run_target()?;
let name = settings.run_targets.iter().find(|t| t.id == target).map(|t| t.name.clone()).unwrap_or_default();
let dir = Dirs::of(host).target(root, target);
let mut out = ContainerPython {
target: target.to_owned(),
name,
version: None,
image: None,
folders: Vec::new(),
packages: Vec::new(),
updating: updating(&dir),
};
if let Some((dir, snapshot)) = snapshots::current(&dir) {
out.folders = snapshots::local_folders(&dir, &snapshot).into_iter().map(Into::into).collect();
out.packages = snapshots::packages(&dir, &snapshot);
out.version = Some(snapshot.probe.version);
out.image = Some(snapshot.image);
}
Some(out)
}
/// Takes a new snapshot of the project interpreter's container (also of an image that
/// has one already), then the language servers pick it up.
pub async fn sdk_container_python_refresh(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
host.require_trust(window)?;
let project = host.project(window)?;
update(Dirs::of(host), &project, true).await?;
Ok(describe(host, &project.root, &project.settings.effective()))
}
/// Takes a snapshot when the project interpreter is a run target's and the current image
/// has none yet (a new choice, a rebuilt image), in the background. Errors are notified.
pub(crate) fn ensure(host: &Host, project: &Arc<Project>) {
if !project.is_trusted() || project.settings.effective().languages.python.run_target().is_none() {
return;
}
let (dirs, project) = (Dirs::of(host), project.clone());
host.runtime.spawn(async move {
if let Err(e) = update(dirs, &project, false).await
&& e.kind != ide_core::error::ErrorKind::Cancelled
{
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Warning,
title: "Cannot copy the container's Python libraries".into(),
body: Some(e.message),
});
}
});
}
async fn update(dirs: Dirs, project: &Arc<Project>, force: bool) -> IdeResult<()> {
let settings = project.settings.effective();
let Some(id) = settings.languages.python.run_target() else { return Ok(()) };
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind == RunTargetKind::Ssh {
return Err(IdeError::invalid(format!(
"Run target '{}' runs over SSH: only a Docker or Compose run target can be the project interpreter",
target.name
)));
}
let dir = dirs.target(&project.root, id);
if !UPDATING.lock().unwrap_or_else(|e| e.into_inner()).insert(dir.clone()) {
return Ok(());
}
let _done = Updating(dir.clone());
let endpoint = crate::ops::run::container::endpoint(&dirs.data, &target)?;
let task = TaskId::next();
let title = format!("Python libraries of {}", target.name);
let (root, sink) = (project.root.clone(), project.sink.clone());
let progress = move |message: &str, done: bool| {
sink.emit(IdeEvent::Task {
id: task,
title: title.clone(),
message: Some(message.to_owned()),
fraction: None,
done,
cancellable: false,
})
};
let report = progress.clone();
let result = tokio::task::spawn_blocking(move || {
take(&endpoint, &root, &target, &dir, &dirs.cache, force, &|message| report(message, false))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))
.and_then(|r| r);
progress(if result.is_ok() { "Done" } else { "Failed" }, true);
if result? {
project.lsp.reconfigure().await;
}
Ok(())
}
struct Updating(PathBuf);
impl Drop for Updating {
fn drop(&mut self) {
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
}
}
/// Removes the probe container, also when copying failed.
struct Container<'a> {
endpoint: &'a ide_docker::DockerEndpoint,
name: String,
}
impl Drop for Container<'_> {
fn drop(&mut self) {
let args = ["rm", "--force", "--volumes", &self.name].map(String::from);
if let Err(e) = ide_docker::cli(self.endpoint, None, &args, QUICK, None) {
tracing::warn!("cannot remove container {}: {}", self.name, e.message);
}
}
}
/// Takes the snapshot in `target_dir` and makes it the one in use; `false` when the image
/// had one already and it is in use (unless `force`).
fn take(
endpoint: &ide_docker::DockerEndpoint,
root: &Path,
target: &RunTarget,
target_dir: &Path,
cache: &Path,
force: bool,
progress: &dyn Fn(&str),
) -> IdeResult<bool> {
crate::ops::run::container::require_local(endpoint, target)?;
let container_root = crate::ops::run::container::container_root(endpoint, root, target)?;
let name = format!("openidea-python-{}", unique());
let args = ide_remote::docker::python_probe_args(target, root, &name, snapshots::PROBE)?;
progress("Starting the container's Python");
let container = Container { endpoint, name };
let out = ide_docker::cli(endpoint, Some(root), &args, PROBE_TIMEOUT, None);
let probe = snapshots::parse_probe(&out?)?;
let inspect = ["inspect".into(), "--format".into(), "{{.Image}}".into(), container.name.clone()];
let image = ide_docker::cli(endpoint, None, &inspect, QUICK, None)?.trim().to_owned();
if image.is_empty() {
return Err(IdeError::internal(format!("docker inspect printed no image for {}", container.name)));
}
let dir = target_dir.join(snapshots::dir_name(&image));
let before = snapshots::current(target_dir).map(|(_, s)| s.image);
if !force && snapshots::read(&dir).is_some() {
snapshots::make_current(target_dir, &image)?;
return Ok(before.as_deref() != Some(image.as_str()));
}
let partial = target_dir.join(format!("{}.partial", snapshots::dir_name(&image)));
if partial.exists() {
std::fs::remove_dir_all(&partial)?;
}
std::fs::create_dir_all(&partial)?;
let mut folders = Vec::new();
for folder in snapshots::folders_to_copy(&probe, &container_root) {
progress(&format!("Copying {folder}"));
let local = snapshots::local_folder(&partial, &folder);
let Some(parent) = local.parent() else { continue };
std::fs::create_dir_all(parent)?;
// Into an existing folder: `docker cp` puts the source folder in it under its own name.
let args = ["cp".into(), format!("{}:{folder}", container.name), format!("{}/", parent.to_string_lossy())];
match ide_docker::cli(endpoint, None, &args, COPY_TIMEOUT, None) {
Ok(_) => folders.push(folder),
Err(e) => tracing::warn!("cannot copy {folder} from {}: {}", container.name, e.message),
}
}
drop(container);
progress("Preparing the language server's interpreter");
let snapshot = Snapshot {
neutral_python: snapshots::neutral_python(cache, &probe.version),
image: image.clone(),
probe,
folders,
};
snapshots::write(&partial, &snapshot)?;
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
std::fs::rename(&partial, &dir)?;
snapshots::make_current(target_dir, &image)?;
Ok(true)
}
/// Distinguishes probe containers.
fn unique() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNT: AtomicU32 = AtomicU32::new(0);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
format!("{:x}-{}-{}", secs & 0xff_ffff, std::process::id(), COUNT.fetch_add(1, Ordering::Relaxed))
}
-3
Переглянути файл
@@ -174,7 +174,6 @@ impl Host {
let root = member.as_ref().unwrap_or(window_root);
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
crate::ops::sdk::container_python::configure_server(cfg, &data_dir, root, &effective);
if cfg.name == "jdtls" {
// A workspace member keeps its build model under its own key (Build Project).
let cache = match &member {
@@ -631,7 +630,6 @@ impl Host {
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
crate::ops::sdk::container_python::ensure(self, &project);
Ok(project)
}
@@ -666,7 +664,6 @@ impl Host {
.join(project_key)
}),
models_file: self.data_dir.join("agents").join("models.json"),
catalogs: self.agent_catalogs.clone(),
custom_agents_dir: Some(self.config_dir.join("agents")),
home_dir: dirs::home_dir(),
env_secrets: Some(Arc::new(AgentEnvSecrets {
-2
Переглянути файл
@@ -289,7 +289,6 @@ impl crate::Host {
}
}
project.lsp.reconfigure().await;
crate::ops::sdk::container_python::ensure(self, &project);
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
}
Ok(())
@@ -358,7 +357,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "docker_set_registry_password"
| "docker_run_env"
| "db_console_path"
| "db_consoles"
| "db_export_file"
| "vcs_write_patch"
| "app_metrics"
-98
Переглянути файл
@@ -1,98 +0,0 @@
//! Separate project windows share discovery and its backend-enforced cooldown.
#![cfg(unix)]
use ide_agent::AgentEvent;
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn windows_share_model_discovery_and_manual_refresh_cooldown() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let config = tmp.path().join("config");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(config.join("agents")).unwrap();
let script = tmp.path().join("catalog.sh");
std::fs::write(
&script,
r#"#!/bin/sh
printf 'launch\n' >> "${0%/*}/launches"
while IFS= read -r line; do
case "$line" in
*'"id":'*) rest=${line#*\"id\":}; id=${rest%%,*}; id=${id%%\}*} ;;
*) continue ;;
esac
case "$line" in
*'"method":"initialize"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$id" ;;
*'"method":"session/new"'*)
while [ ! -f "${0%/*}/release" ]; do sleep 0.01; done
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"catalog","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"new-model","options":[{"value":"new-model","name":"New model"}]}]}}\n' "$id" ;;
esac
done
"#,
)
.unwrap();
std::fs::write(
config.join("agents/catalog-test.toml"),
format!(
"name = \"Catalog test\"\ncommand = \"/bin/sh\"\nargs = [{}]\n",
serde_json::to_string(&script).unwrap()
),
)
.unwrap();
let host = Host::new(HostConfig {
config_dir: config,
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("first", &root).unwrap();
host.open_project("second", &root).unwrap();
let (send, mut events) = tokio::sync::mpsc::unbounded_channel();
for window in ["first", "second"] {
let send = send.clone();
host.project(window).unwrap().agents.subscribe(Arc::new(move |event| {
if let AgentEvent::ModelCatalog { catalog } = event {
let _ = send.send((window, catalog));
}
}));
}
for _ in 0..20 {
for window in ["first", "second"] {
let state = ops::agent::refresh_models(&host, window, "catalog-test".into(), true).unwrap();
assert!(state.refreshing);
}
}
std::fs::write(tmp.path().join("release"), "").unwrap();
let completed = tokio::time::timeout(Duration::from_secs(10), async {
let mut windows = std::collections::HashSet::new();
while windows.len() < 2 {
let (window, catalog) = events.recv().await.expect("catalog event stream closed");
if !catalog.refreshing && catalog.refreshed_at_ms.is_some() {
assert!(catalog.error.is_none(), "{:?}", catalog.error);
windows.insert(window);
}
}
})
.await;
assert!(completed.is_ok(), "both windows must receive the completed catalog");
for _ in 0..20 {
for window in ["first", "second"] {
let catalog = ops::agent::refresh_models(&host, window, "catalog-test".into(), true).unwrap();
assert!(!catalog.refreshing);
assert!(catalog.retry_at_ms.is_some());
let models = catalog.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
assert_eq!(models.values[0].value, "new-model");
}
}
assert_eq!(std::fs::read_to_string(tmp.path().join("launches")).unwrap(), "launch\n");
}
-174
Переглянути файл
@@ -1,174 +0,0 @@
//! A Compose run target as the Python interpreter, against a real daemon and the real
//! basedpyright. Ignored by default: `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the
//! daemon can bind-mount (on this machine, or at the same path in a VM such as OrbStack's),
//! `docker` with the Compose plugin and `basedpyright-langserver` must be on `PATH`, and
//! this machine's Python must not have Django installed.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
type Events = Arc<Mutex<Vec<IdeEvent>>>;
fn host(base: &Path, events: &Events) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new({
let events = events.clone();
move |_| {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
}
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
/// basedpyright's latest diagnostics of `path` once `done` accepts them.
async fn diagnostics(events: &Events, path: &Path, timeout: Duration, done: impl Fn(&[String]) -> bool) -> Vec<String> {
let deadline = Instant::now() + timeout;
loop {
let latest = events.lock().unwrap().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path: p, items, source } if **p == *path && source.contains("basedpyright") => {
Some(items.iter().map(|d| d.message.clone()).collect::<Vec<_>>())
}
_ => None,
});
if let Some(messages) = &latest
&& done(messages)
{
return messages.clone();
}
assert!(Instant::now() < deadline, "timed out; last diagnostics: {latest:?}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
fn unresolved(messages: &[String]) -> bool {
messages.iter().any(|m| m.contains("\"django") && m.contains("could not be resolved"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "requires a Docker daemon, basedpyright and OPENIDEA_DOCKER_TEST_DIR"]
async fn compose_target_is_the_python_interpreter() {
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
let root = dunce::canonicalize(root).unwrap();
let files = [
("compose.yaml", "services:\n web:\n build: .\n volumes:\n - .:/app\n working_dir: /app\n"),
(
"Dockerfile",
"FROM python:3.12-slim\nCOPY requirements.txt /tmp/requirements.txt\n\
RUN pip install --no-cache-dir -r /tmp/requirements.txt\n",
),
("requirements.txt", "Django==5.1.3\n"),
(
"app/main.py",
"import django\nfrom django.http import HttpResponse\n\nprint(django.get_version(), HttpResponse)\n",
),
];
for (path, text) in files {
std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
std::fs::write(root.join(path), text).unwrap();
}
let main = root.join("app/main.py");
let state = tempfile::tempdir().unwrap();
let events: Events = Arc::default();
let host = host(state.path(), &events);
host.trust.decide(&root, true, false).unwrap();
host.open_project(WINDOW, &root).unwrap();
let set = |key: &str, value: serde_json::Value| {
ops::project::set_settings(
&host,
WINDOW,
ide_workspace::settings::SettingsScope::Project,
key,
&value.to_string(),
)
.unwrap();
};
set("runTargets", serde_json::json!([{ "kind": "compose", "id": "web", "name": "Compose web", "service": "web" }]));
// A local interpreter: Django is only in the image.
ops::doc::open(&host, WINDOW, main.clone()).await.unwrap();
let before = diagnostics(&events, &main, Duration::from_secs(120), unresolved).await;
eprintln!("local interpreter: {before:?}");
// The run target as the interpreter: a snapshot is taken in the background, then the
// server restarts with the copied site-packages as `extraPaths`.
set("languages.python.interpreter", serde_json::json!("target:web"));
let after = diagnostics(&events, &main, Duration::from_secs(900), |m| !unresolved(m)).await;
eprintln!("run target interpreter: {after:?}");
assert!(after.iter().all(|m| !m.contains("could not be resolved")), "{after:?}");
let info = ops::sdk::sdk_container_python(&host, WINDOW).unwrap().expect("a run target interpreter");
eprintln!("snapshot: version {:?}, image {:?}, folders {:?}", info.version, info.image, info.folders);
assert_eq!(info.name, "Compose web");
assert!(info.version.as_deref().is_some_and(|v| v.starts_with("3.12.")), "{info:?}");
assert!(info.packages.iter().any(|p| p.name == "Django" && p.version == "5.1.3"), "{:?}", info.packages);
let site = info.folders.iter().find(|f| f.0.ends_with("site-packages")).expect("site-packages copied");
assert!(site.0.join("django/__init__.py").is_file());
assert!(site.0.starts_with(state.path().join("data/container-python")), "{site:?}");
let servers = ops::lsp::lsp_servers(&host, WINDOW).unwrap();
eprintln!("servers: {:?}", servers.iter().map(|s| (&s.name, s.status)).collect::<Vec<_>>());
// Run: a Python configuration without its own target runs in the container.
let out = Arc::new(Mutex::new(String::new()));
let sink = out.clone();
let config = RunConfig {
id: "main".into(),
name: "main".into(),
kind: "python".into(),
command: "python3".into(),
args: vec!["app/main.py".into()],
cwd: None,
env: Default::default(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
ops::run::start(
&host,
WINDOW,
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().unwrap().push_str(&text)
}
}),
)
.await
.unwrap();
for _ in 0..1200 {
if out.lock().unwrap().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let out = out.lock().unwrap().clone();
eprintln!("run output: {out}");
assert!(out.contains("5.1.3") && out.contains("exit code 0"), "{out}");
// Refresh takes a new snapshot of the same image; nothing is left running.
let refreshed = ops::sdk::sdk_container_python_refresh(&host, WINDOW).await.unwrap().unwrap();
assert_eq!(refreshed.image, info.image);
let probes = Command::new("docker")
.args(["ps", "--all", "--quiet", "--filter", "label=openidea.python-probe=true"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&probes.stdout).trim(), "", "probe containers are removed");
Command::new("docker").args(["compose", "down", "--rmi", "local"]).current_dir(&root).status().unwrap();
}
-37
Переглянути файл
@@ -1,37 +0,0 @@
use ide_core::NullSink;
use ide_host::ops::db;
use ide_host::{Host, HostConfig};
use std::sync::Arc;
#[tokio::test]
async fn new_query_consoles_are_numbered_after_the_default_one() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let names = |paths: Vec<ide_core::IpcPath>| -> Vec<String> {
paths.iter().map(|p| p.0.file_name().unwrap().to_string_lossy().into_owned()).collect()
};
assert!(db::consoles(&host, "pg", false).unwrap().is_empty());
// The first new console is the default one, then console_1, console_2…
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql"]);
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql", "console_1.sql"]);
let all = db::consoles(&host, "pg", true).unwrap();
assert_eq!(names(all.clone()), ["console.sql", "console_1.sql", "console_2.sql"]);
// Numbers sort numerically, and the default console path is the first one.
std::fs::write(all[0].0.with_file_name("console_10.sql"), "").unwrap();
assert_eq!(names(db::consoles(&host, "pg", false).unwrap())[3], "console_10.sql");
assert_eq!(db::console_path(&host, "pg").unwrap().0, all[0].0);
// Each database of a source has its own consoles.
assert!(db::consoles(&host, "pg:other", false).unwrap().is_empty());
assert!(db::consoles(&host, "../x", true).is_err());
}
-141
Переглянути файл
@@ -1,141 +0,0 @@
//! The native `nav_inheritance` command: it must return every Python class definition
//! in the project — attached dependency roots included — together with the base names
//! written in its source, so the UI can compute inheritance gutter markers without a
//! language server's type-hierarchy provider.
use ide_host::{Host, HostConfig, ops};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
/// Opens a synthetic project whose Python files carry known class / base shapes, then
/// waits for the index to settle (it walks content roots, so dependency roots are covered).
async fn open() -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("proj");
let src = root.join("src");
let dep = root.join("vendor");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dep).unwrap();
// Own root: a plain class, a class with two bases + a keyword arg, a qualified base,
// and a subscripted generic base. A comment / docstring that mentions `class X(Y)`
// must not be reported.
std::fs::write(
src.join("app.py"),
concat!(
"class Base:\n",
" pass\n",
"\n",
"class Mixin:\n",
" pass\n",
"\n",
"class Child(Base, Mixin, metaclass=Meta):\n",
" # class Ghost(Spook): must be ignored\n",
" \"\"\"class Phantom(Apparition): must be ignored\"\"\"\n",
" pass\n",
"\n",
"class Qualified(pkg.mod.Base):\n",
" pass\n",
"\n",
"class Generic(Generic[T]):\n",
" pass\n",
),
)
.unwrap();
// A second own-root file to prove the command scans more than one file.
std::fs::write(src.join("extra.pyi"), "class Stub(Base):\n ...\n").unwrap();
// An attached dependency root (content root) — must be covered by the index.
std::fs::write(dep.join("dep.py"), "class DepBase:\n pass\n").unwrap();
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let s = ops::nav::index_status(&host, WINDOW).unwrap();
if !s.indexing && s.files > 0 {
break;
}
assert!(Instant::now() < deadline, "indexing timed out; status: {s:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
Opened { host, root, _tmp: tmp }
}
fn find<'a>(all: &'a [ide_host::ops::nav::ClassInheritance], name: &str) -> &'a ide_host::ops::nav::ClassInheritance {
all.iter().find(|c| c.name == name).unwrap_or_else(|| panic!("no class {name:?} in {all:#?}"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn returns_all_python_classes_and_bases() {
let p = open().await;
let all = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
// Both own-root Python files are scanned (src/app.py + src/extra.pyi).
let base = find(&all, "Base");
assert!(base.path.0.ends_with("src/app.py"), "Base path: {:?}", base.path);
// The location is the class name identifier (selection-range start), i.e. after `class `.
assert_eq!(base.line, 0);
assert_eq!(base.col, 6);
assert!(base.bases.is_empty(), "a bare class has no bases: {:?}", base.bases);
let child = find(&all, "Child");
assert_eq!(child.line, 6);
assert_eq!(child.col, 6);
// The `metaclass=Meta` keyword argument is not a base.
assert!(child.bases.contains(&"Base".to_string()), "Child bases: {:?}", child.bases);
assert!(child.bases.contains(&"Mixin".to_string()), "Child bases: {:?}", child.bases);
assert!(!child.bases.iter().any(|b| b == "Meta"), "metaclass must be skipped: {:?}", child.bases);
// A qualified base keeps only its last segment.
let qualified = find(&all, "Qualified");
assert_eq!(qualified.bases, vec!["Base".to_string()], "qualified base: {:?}", qualified.bases);
// A subscripted generic base keeps the identifier.
let generic = find(&all, "Generic");
assert_eq!(generic.bases, vec!["Generic".to_string()], "generic base: {:?}", generic.bases);
// Comments / docstrings are not mistaken for class definitions.
assert!(!all.iter().any(|c| c.name == "Ghost" || c.name == "Phantom"), "parsed a comment: {all:#?}");
// The .pyi stub in the second own-root file is included.
let stub = find(&all, "Stub");
assert!(stub.path.0.ends_with("src/extra.pyi"), "Stub path: {:?}", stub.path);
assert_eq!(stub.bases, vec!["Base".to_string()]);
// The attached dependency root is covered by the project index.
let dep = find(&all, "DepBase");
assert!(dep.path.0.starts_with(&p.root), "DepBase path: {:?}", dep.path);
// Every returned entry is a Python file.
assert!(all.iter().all(|c| { matches!(c.path.0.extension().and_then(|e| e.to_str()), Some("py" | "pyi")) }));
// A file changed on disk is parsed again; the others come from the cache.
let extra = p.root.join("src/extra.pyi");
std::fs::write(&extra, "class Stub(Child): ...\nclass Added(Base): ...\n").unwrap();
let again = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
assert_eq!(find(&again, "Stub").bases, vec!["Child".to_string()]);
assert_eq!(find(&again, "Added").bases, vec!["Base".to_string()]);
assert_eq!(find(&again, "Child").bases, child.bases);
}
+1 -4
Переглянути файл
@@ -82,9 +82,7 @@ async fn serves_projects_files_documents_search_and_runs() {
invoke(&peer, "lsp_resend_diagnostics", json!({})).await.unwrap();
let files = invoke(&peer, "find_files", json!({"query": "main", "limit": 10})).await.unwrap();
assert!(files.is_array());
for command in
["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start", "agent_refresh_models"]
{
for command in ["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start"] {
let error = invoke(&peer, command, json!({})).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
}
@@ -367,7 +365,6 @@ async fn local_deployment_runs_on_the_project_host() {
"db_table_page",
"db_apply_edits",
"db_console_path",
"db_consoles",
"ssh_test",
"ssh_set_secret",
"ssh_password_saved",
-2
Переглянути файл
@@ -66,7 +66,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"docker_set_registry_password",
"docker_run_env",
"db_console_path",
"db_consoles",
"db_export_file",
"vcs_write_patch",
"remote_bootstrap_list",
@@ -93,7 +92,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"vcs_git_version",
"docker_compose_op",
"db_execute",
"agent_refresh_models",
"remote_delete",
"remote_mkdir",
"remote_rename",
-22
Переглянути файл
@@ -599,28 +599,6 @@ impl Store {
txn.commit().map_err(err)
}
/// [`Self::apply`] for records already serialized (by the parse pool).
pub(crate) fn apply_raw(&self, puts: &[(String, Vec<u8>)], deletes: &[String], durable: bool) -> Result<()> {
self.ensure_db()?;
let db = self.db.read();
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
if !durable {
txn.set_durability(Durability::None).map_err(err)?;
}
{
let mut table = txn.open_table(FILES).map_err(err)?;
for (rel, bytes) in puts {
table.insert(rel.as_str(), bytes.as_slice()).map_err(err)?;
}
for rel in deletes {
table.remove(rel.as_str()).map_err(err)?;
}
}
// Any file change makes the columnar start-up snapshot stale.
let _ = std::fs::remove_file(&self.snapshot);
txn.commit().map_err(err)
}
/// Reclaims free pages in the database file; `true` if anything was compacted.
pub(crate) fn compact(&self) -> Result<bool> {
self.ensure_db()?;
+48 -164
Переглянути файл
@@ -17,8 +17,6 @@ use std::time::{Duration, Instant, SystemTime};
/// Files larger than this are listed but not parsed for symbols.
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
const CHUNK: usize = 512;
/// Insert/persist batch of the initial scan's streaming pass.
const SCAN_BATCH: usize = 2048;
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
#[cfg(not(test))]
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
@@ -438,13 +436,10 @@ impl Shared {
});
let filter = self.filter();
let walk_started = Instant::now();
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
let walk = walk_started.elapsed();
if self.is_cancelled() {
return self.end_scan(task);
}
let classify_started = Instant::now();
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
let d = self.data.read();
@@ -457,7 +452,6 @@ impl Shared {
.collect();
(todo, stale)
};
let classify = classify_started.elapsed();
if !stale.is_empty() {
let mut d = self.data.write();
for path in &stale {
@@ -468,76 +462,33 @@ impl Shared {
self.persist(&[], &stale, false);
let total = todo.len();
let mut laps = ChunkLaps::default();
{
// One streaming parallel pass: parse on the rayon pool without
// per-chunk joins (a straggler file at a join's tail idles every
// other core), while parsed files flow to this thread to be
// inserted and persisted in batches. `filter` moves into the
// parse thread; this thread keeps the outer one.
let filter = self.filter();
// Bounded: when inserting and persisting fall behind, the parse waits instead
// of holding the whole project's records (and their bytes) in the channel.
let (tx, rx) = std::sync::mpsc::sync_channel::<ParsedFile>(2 * SCAN_BATCH);
let cancelled = &self.cancelled;
let mut done = 0usize;
let mut last_progress = Instant::now();
let pass_started = Instant::now();
std::thread::scope(|scope| -> () {
scope.spawn(move || {
todo.par_iter().for_each(|f| {
if cancelled.load(Ordering::Relaxed) {
return;
}
let Some(out) = self.parse_found(f, &filter, false) else { return };
let _ = tx.send(out);
});
// `tx` drops here: the consumer's loop ends with the channel.
let mut done = 0usize;
let mut last_progress = Instant::now();
for chunk in todo.chunks(CHUNK) {
if self.is_cancelled() {
return self.end_scan(task);
}
self.index_files(chunk.iter().copied(), false);
done += chunk.len();
if last_progress.elapsed() >= PROGRESS_EVERY {
last_progress = Instant::now();
self.sink.emit(IdeEvent::Task {
id: task,
title: "Indexing".into(),
message: Some(format!("{done} / {total} files")),
fraction: Some(done as f32 / total.max(1) as f32),
done: false,
cancellable: false,
});
let mut batch: Vec<ParsedFile> = Vec::with_capacity(SCAN_BATCH);
for out in rx {
if self.is_cancelled() {
break;
}
batch.push(out);
if batch.len() < SCAN_BATCH {
continue;
}
done += batch.len();
laps.add(self.publish(batch.drain(..)));
if last_progress.elapsed() >= PROGRESS_EVERY {
last_progress = Instant::now();
self.sink.emit(IdeEvent::Task {
id: task,
title: "Indexing".into(),
message: Some(format!("{done} / {total} files")),
fraction: Some(done as f32 / total.max(1) as f32),
done: false,
cancellable: false,
});
}
}
if !batch.is_empty() && !self.is_cancelled() {
done += batch.len();
laps.add(self.publish(batch.drain(..)));
}
});
laps.extract = pass_started.elapsed();
}
}
if self.is_cancelled() {
return self.end_scan(task);
}
let durable_started = Instant::now();
self.persist(&[], &[], true);
let durable = durable_started.elapsed();
if let Some(store) = &self.store {
let recent: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
let d = self.data.read();
let snapshot_started = Instant::now();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
tracing::info!("index snapshot: written in {:?}", snapshot_started.elapsed());
if let Err(e) = store.save_recent_file(&recent) {
tracing::warn!("index recent snapshot: {e}");
}
@@ -555,85 +506,54 @@ impl Shared {
self.emit_state();
let d = self.data.read();
tracing::info!(
"indexed {} files ({} parsed, {} symbols) in {:?} (walk {:?}, classify {:?}, extract {:?}, insert {:?}, persist {:?}, durable {:?})",
"indexed {} files ({} parsed, {} symbols) in {:?}",
d.file_count,
total,
d.symbol_count,
started.elapsed(),
walk,
classify,
laps.extract,
laps.insert,
laps.persist,
durable
started.elapsed()
);
}
/// Extracts records in parallel, then publishes them to memory and disk.
/// `force` re-parses files whose content is unchanged (their language changed).
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) -> ChunkLaps {
let mut laps = ChunkLaps::default();
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) {
let files: Vec<&Found> = files.collect();
let filter = self.filter();
let started = Instant::now();
let extracted: Vec<ParsedFile> = files.par_iter().filter_map(|f| self.parse_found(f, &filter, force)).collect();
laps.extract = started.elapsed();
let extracted: Vec<(String, FileRecord, PathBuf)> = files
.par_iter()
.filter_map(|f| {
if self.is_cancelled() {
return None;
}
let rel = filter.rel(&f.path)?;
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
match self.extract(f, prev_hash) {
Extracted::Unchanged => {
// Same content, new mtime: keep the symbols, refresh metadata.
let mut rec = self.data.read().record_of(&f.path)?;
rec.mtime = f.mtime;
rec.size = f.size;
rec.ignored = f.ignored;
Some((rel, rec, f.path.clone()))
}
Extracted::New(rec) => Some((rel, rec, f.path.clone())),
}
})
.collect();
if extracted.is_empty() {
return laps;
}
laps.add(self.publish(extracted.into_iter()));
laps
}
/// Reads and parses one file (parallel-safe), serializing its record for
/// the cache alongside the parse. `None` when the file is outside the
/// scope or the scan was cancelled.
fn parse_found(&self, f: &Found, filter: &std::sync::Arc<PathFilter>, force: bool) -> Option<ParsedFile> {
if self.is_cancelled() {
return None;
}
let rel = filter.rel(&f.path)?;
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
let rec = match self.extract(f, prev_hash) {
Extracted::Unchanged => {
// Same content, new mtime: keep the symbols, refresh metadata.
let mut rec = self.data.read().record_of(&f.path)?;
rec.mtime = f.mtime;
rec.size = f.size;
rec.ignored = f.ignored;
rec
}
Extracted::New(rec) => rec,
};
let key = filter.store_key(&f.path);
let bytes = postcard::to_allocvec(&rec).ok();
Some(ParsedFile { rel, rec, key, bytes, path: f.path.clone() })
}
/// Inserts a parsed batch into the in-memory index and persists the records.
fn publish(&self, batch: impl Iterator<Item = ParsedFile>) -> ChunkLaps {
let mut laps = ChunkLaps::default();
let started = Instant::now();
let mut entries: Vec<(FileEntry, Vec<StoredSymbol>)> = Vec::new();
let mut puts: Vec<(String, Vec<u8>)> = Vec::new();
for out in batch {
let ParsedFile { rel, rec, key, bytes, path } = out;
if let (Some(key), Some(bytes)) = (key, bytes) {
puts.push((key, bytes));
}
entries.push(FileEntry::new(path, rel, rec));
return;
}
let puts: Vec<(String, FileRecord)> =
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
{
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
let mut d = self.data.write();
for (e, symbols) in entries {
d.insert(e, symbols);
}
}
laps.insert = started.elapsed();
let started = Instant::now();
self.persist_raw(&puts, &[], false);
laps.persist = started.elapsed();
laps
self.persist(&puts, &[], false);
}
fn schedule_snapshot(&self) {
@@ -674,15 +594,6 @@ impl Shared {
}
}
/// [`Self::persist`] for records already serialized by the parse pool.
fn persist_raw(&self, puts: &[(String, Vec<u8>)], deletes: &[String], durable: bool) {
if let Some(store) = &self.store
&& let Err(e) = store.apply_raw(puts, deletes, durable)
{
tracing::warn!("index cache write failed: {e}");
}
}
fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted {
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ignored: f.ignored, ..Default::default() };
if f.ignored || f.size > MAX_SYMBOL_FILE {
@@ -882,33 +793,6 @@ enum Extracted {
New(FileRecord),
}
/// Wall-time laps of one `index_files` chunk, accumulated by the scan.
#[derive(Default)]
struct ChunkLaps {
extract: Duration,
insert: Duration,
persist: Duration,
}
impl ChunkLaps {
fn add(&mut self, other: ChunkLaps) {
self.extract += other.extract;
self.insert += other.insert;
self.persist += other.persist;
}
}
/// One file parsed off the index thread: its record ready to publish, already
/// serialized for the cache (in parallel with the parse).
struct ParsedFile {
rel: String,
rec: FileRecord,
/// Cache key and postcard bytes; `None` when outside the persisted root.
key: Option<String>,
bytes: Option<Vec<u8>>,
path: PathBuf,
}
#[cfg(test)]
mod tests {
use crate::data::safe_rel;
+5 -9
Переглянути файл
@@ -22,11 +22,11 @@ pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, ClassBases, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax,
ExternalToken, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind,
SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError,
VueComponentImport, VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility,
set_external_syntax, shift_pos, shift_range,
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, VueComponentImport,
VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility, set_external_syntax, shift_pos,
shift_range,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -198,10 +198,6 @@ pub struct LanguageServerConfig {
/// it parses a document.
#[serde(default)]
pub resync_on_configuration: bool,
/// Set by the settings hook: what the user sees as the server when another program serves
/// in its place (TypeScript 7's own `tsc --lsp` for vtsls).
#[serde(skip)]
pub display_name: Option<String>,
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
-171
Переглянути файл
@@ -1,171 +0,0 @@
//! Extraction of Python class base names, used to build an inheritance graph
//! without an LSP `typeHierarchyProvider`.
use super::lines::LineIndex;
use crate::{LanguageId, Syntax};
use tree_sitter::{Node, Tree};
/// A Python class definition and the base names written in its source.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClassBases {
/// The class name (bare identifier).
pub name: String,
/// 0-based line of the class name (selection-range start).
pub line: u32,
/// 0-based UTF-16 column of the class name (selection-range start).
pub col: u32,
/// Source-written base names (last segment of a qualified name).
pub bases: Vec<String>,
}
impl Syntax {
/// Class definitions of `text` with their base names, in source order.
/// Only Python is supported; every other language returns an empty vector.
pub fn class_bases(&self, lang: &LanguageId, text: &str) -> Vec<ClassBases> {
self.analyze(lang, text, |g, tree, text| (g.spec.name == "python").then(|| extract(tree, text)))
.unwrap_or_default()
}
}
fn extract(tree: &Tree, text: &str) -> Vec<ClassBases> {
let idx = LineIndex::new(text);
let mut out = 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.get(name_node.byte_range()).unwrap_or_default().to_owned();
if name.is_empty() {
return;
}
// 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();
out.push(ClassBases { name, line: range.start.line, col: range.start.col, bases });
});
out
}
/// 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;
}
}
}
}
fn base_names(args: Node, text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
if let Some(name) = base_name(arg, text) {
out.push(name);
}
}
out
}
/// Bare name of one base expression, or `None` for keyword arguments and
/// expressions that are not a (possibly qualified) name.
fn base_name(arg: Node, text: &str) -> Option<String> {
match arg.kind() {
"identifier" => text.get(arg.byte_range()).map(str::to_owned),
// `pkg.mod.Base` -> `Base`.
"attribute" => {
let attr = arg.child_by_field_name("attribute")?;
(attr.kind() == "identifier").then(|| text.get(attr.byte_range()).unwrap_or_default().to_owned())
}
// `Generic[T]` -> `Generic`.
"subscript" => {
let value = arg.child_by_field_name("value")?;
(value.kind() == "identifier").then(|| text.get(value.byte_range()).unwrap_or_default().to_owned())
}
// `metaclass=Meta`, `**kwargs`, `*bases` and other expressions.
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bases(src: &str) -> Vec<ClassBases> {
static S: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
S.get_or_init(Syntax::new).class_bases(&LanguageId::new("python"), src)
}
fn shape(src: &str) -> Vec<(String, u32, u32, Vec<String>)> {
bases(src).into_iter().map(|c| (c.name, c.line, c.col, c.bases)).collect()
}
#[test]
fn no_bases() {
assert_eq!(shape("class A: pass"), vec![("A".to_owned(), 0, 6, vec![])]);
}
#[test]
fn multiple_bases_skips_metaclass() {
assert_eq!(
shape("class B(A, C, metaclass=Meta): pass"),
vec![("B".to_owned(), 0, 6, vec!["A".to_owned(), "C".to_owned()])]
);
}
#[test]
fn qualified_name_takes_last_segment() {
assert_eq!(shape("class D(pkg.mod.Base): pass"), vec![("D".to_owned(), 0, 6, vec!["Base".to_owned()])]);
}
#[test]
fn subscript_takes_value() {
assert_eq!(shape("class E(Generic[T]): pass"), vec![("E".to_owned(), 0, 6, vec!["Generic".to_owned()])]);
}
#[test]
fn multiline_bases() {
let src = "class F(\n A,\n B,\n):\n pass";
assert_eq!(shape(src), vec![("F".to_owned(), 0, 6, vec!["A".to_owned(), "B".to_owned()])]);
}
#[test]
fn nested_classes() {
let src = "class Outer:\n class Inner(Y):\n pass";
assert_eq!(
shape(src),
vec![("Outer".to_owned(), 0, 6, vec![]), ("Inner".to_owned(), 1, 10, vec!["Y".to_owned()]),]
);
}
#[test]
fn comments_and_strings_are_ignored() {
let src = r#"
# class Fake(Nope): pass
doc = """
class InDocstring(X): pass
"""
s = 'class InString(Y): pass'
class Real(Base): pass
"#;
assert_eq!(shape(src), vec![("Real".to_owned(), 6, 6, vec!["Base".to_owned()])]);
}
#[test]
fn other_languages_are_empty() {
static S: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
let s = S.get_or_init(Syntax::new);
assert!(s.class_bases(&LanguageId::new("rust"), "struct A;").is_empty());
}
}
-2
Переглянути файл
@@ -6,7 +6,6 @@ mod external;
mod folding;
mod grammars;
mod highlight;
mod inheritance;
pub(crate) mod lines;
mod literals;
mod outline;
@@ -23,7 +22,6 @@ pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
pub use inheritance::ClassBases;
pub use lines::{shift_pos, shift_range};
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
pub use outline::merge_visibility;
-1
Переглянути файл
@@ -3855,7 +3855,6 @@ impl LspManager {
languages: s.languages(),
status: s.status(),
pid: s.running().and_then(|r| r.peer.pid()),
label: s.display_name(),
})
.collect()
}
+1 -12
Переглянути файл
@@ -213,11 +213,6 @@ impl Server {
config
}
/// The configured server's display name for the current (or last) instance.
pub fn display_name(&self) -> Option<String> {
self.effective.lock().as_ref().and_then(|c| c.display_name.clone())
}
pub fn set_effective_config(&self, generation: u64, config: LanguageServerConfig) {
let st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_)) {
@@ -512,13 +507,7 @@ impl Server {
return;
}
*cur = (status, message.clone());
st.events.push_back(IdeEvent::LanguageServer {
server: self.id,
name: self.name.clone(),
label: self.display_name(),
status,
message,
});
st.events.push_back(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
}
fn queue_cleanup(&self, st: &mut State) {
-4
Переглянути файл
@@ -341,10 +341,6 @@ pub struct ServerInfo {
pub status: ServerStatus,
/// OS process of the running instance (for memory attribution).
pub pid: Option<u32>,
/// What the user sees as the server when another program serves in its place.
#[serde(default)]
#[specta(optional)]
pub label: Option<String>,
}
/// Read-only contents a language server provides for a non-`file:` URI
-1
Переглянути файл
@@ -268,7 +268,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
}
}
-3
Переглянути файл
@@ -426,7 +426,6 @@ async fn missing_binary_is_reported_with_install_hint() {
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
@@ -699,7 +698,6 @@ async fn typescript_language_server_end_to_end() {
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
@@ -752,7 +750,6 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
}
}
+3 -54
Переглянути файл
@@ -109,17 +109,6 @@ pub(crate) fn cpuprofile_runs_code_of(json: &str, root: &Path) -> bool {
})
}
/// The file on disk of one `$vscode.locations` entry and its 0-based line. The entry lists a
/// frame's places in order: the source it was mapped to, then the script that ran, which a
/// page serves from a URL and has no file.
fn source_location(entry: &Value) -> Option<(PathBuf, u32)> {
entry.get("locations")?.as_array()?.iter().find_map(|l| {
let path = Path::new(l.pointer("/source/path")?.as_str()?);
let line = l.get("lineNumber")?.as_u64()?;
path.is_absolute().then(|| (path.to_path_buf(), line.saturating_sub(1) as u32))
})
}
/// Adds a V8 CPU profile (`node --cpu-prof`), weighted by sampled time in milliseconds.
pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), String> {
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
@@ -157,8 +146,6 @@ pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), Stri
}
}
}
// A recording made through a debugger (js-debug) says where each frame is in the sources.
let mapped = v.pointer("/$vscode/locations").and_then(Value::as_array);
let frame_of = |n: &Value| -> Option<FrameKey> {
let cf = n.get("callFrame")?;
let name = cf.get("functionName").and_then(Value::as_str).unwrap_or_default();
@@ -166,22 +153,10 @@ pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), Stri
if name == "(root)" {
return None;
}
let file = url_path(url);
let name = if name.is_empty() { "(anonymous)".to_owned() } else { name.to_owned() };
let (file, line) = n
.get("locationId")
.and_then(Value::as_u64)
.and_then(|id| mapped?.get(id as usize))
.and_then(source_location)
.map(|(file, line)| (Some(file), Some(line)))
.unwrap_or_else(|| {
let file = url_path(url);
let line = file
.as_ref()
.and(cf.get("lineNumber").and_then(Value::as_i64))
.filter(|l| *l >= 0)
.map(|l| l as u32);
(file, line)
});
let line =
file.as_ref().and(cf.get("lineNumber").and_then(Value::as_i64)).filter(|l| *l >= 0).map(|l| l as u32);
Some(FrameKey { name, file, line, kind: ProfileFrameKind::Code })
};
for (id, weight) in self_time {
@@ -326,32 +301,6 @@ mod tests {
assert!(add_cpuprofile(&mut TreeBuilder::new(), "{}").is_err());
}
#[test]
fn cpuprofile_frames_take_the_source_a_debugger_mapped_them_to() {
// js-debug's recording of a page: scripts are URLs, and `$vscode.locations` holds where
// each frame is in the sources (1-based lines), first the mapped file, then the script.
let json = r#"{"nodes":[
{"id":1,"callFrame":{"functionName":"(root)","url":"","lineNumber":-1},"children":[2,3,4]},
{"id":2,"callFrame":{"functionName":"increment","url":"http://localhost:5173/src/App.vue","lineNumber":40},"hitCount":3,"locationId":0},
{"id":3,"callFrame":{"functionName":"inline","url":"http://localhost:5173/","lineNumber":9},"hitCount":1,"locationId":1},
{"id":4,"callFrame":{"functionName":"(idle)","url":"","lineNumber":-1},"hitCount":1,"locationId":2}],
"$vscode":{"rootPath":"/work/shop","locations":[
{"locations":[
{"lineNumber":13,"columnNumber":10,"source":{"path":"/work/shop/src/App.vue","sourceReference":0}},
{"lineNumber":41,"columnNumber":21,"source":{"path":"localhost꞉5173/src/App.vue","sourceReference":7}}]},
{"locations":[{"lineNumber":10,"columnNumber":1,"source":{"path":"localhost꞉5173/","sourceReference":9}}]},
{"locations":[]}]}}"#;
let mut b = TreeBuilder::new();
add_cpuprofile(&mut b, json).unwrap();
let p = b.finish("node", "samples", ProfileMode::Cpu);
let frame = |name: &str| p.frames.iter().find(|f| f.name == name).unwrap();
assert_eq!(frame("increment").file.as_deref(), Some(Path::new("/work/shop/src/App.vue")));
assert_eq!(frame("increment").line, Some(12));
// No file on disk behind the frame: it stays without one.
assert_eq!((frame("inline").file.as_deref(), frame("inline").line), (None, None));
assert_eq!(frame("(idle)").file, None);
}
#[test]
fn cpuprofile_without_deltas_uses_hit_counts() {
let json = r#"{"nodes":[{"id":1,"callFrame":{"functionName":"(root)","url":""},"children":[2]},
-69
Переглянути файл
@@ -178,47 +178,6 @@ pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<Run
Ok(out)
}
/// The docker CLI arguments that run `python -c code` in a new container of the target's
/// image or service, named `name`. The container is kept when it exits, so `docker cp` can
/// copy from it while nothing runs; the caller removes it. The image's entrypoint is
/// skipped (it may wait for other services), as are the service's dependencies.
pub fn python_probe_args(target: &RunTarget, root: &Path, name: &str, code: &str) -> IdeResult<Vec<String>> {
let python = match target.python.trim() {
"" => "python3",
python => python,
};
let mut args: Vec<String> = Vec::new();
match target.kind {
RunTargetKind::Docker => {
if target.image.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name)));
}
args.push("run".into());
}
RunTargetKind::Compose => {
if target.service.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name)));
}
args.push("compose".into());
for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file".into(), root.join(file).to_string_lossy().into_owned()]);
}
args.extend(["run", "--no-deps", "--no-TTY"].map(String::from));
}
RunTargetKind::Ssh => {
return Err(IdeError::invalid(format!("Run target '{}' is not a container", target.name)));
}
}
args.extend(["--name".into(), name.to_owned(), "--label".into(), "openidea.python-probe=true".into()]);
args.extend(["--entrypoint".into(), python.to_owned()]);
args.push(match target.kind {
RunTargetKind::Compose => target.service.trim().to_owned(),
_ => target.image.trim().to_owned(),
});
args.extend(["-c".into(), code.to_owned()]);
Ok(args)
}
/// Project managers whose `run` executes in the project's environment (`uv run python …`).
const PYTHON_RUNNERS: [&str; 6] = ["uv", "poetry", "pdm", "pipenv", "hatch", "rye"];
@@ -470,34 +429,6 @@ mod tests {
assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service"));
}
#[cfg(unix)]
#[test]
fn python_probe_runs_the_interpreter_in_a_kept_container() {
let root = Path::new("/work/proj");
let target = RunTarget {
kind: RunTargetKind::Compose,
compose_files: vec!["compose.yaml".into()],
service: "web".into(),
python: "/venv/bin/python".into(),
run_options: "--rm".into(),
..Default::default()
};
assert_eq!(
python_probe_args(&target, root, "openidea-python-1", "print(1)").unwrap().join(" "),
"compose --file /work/proj/compose.yaml run --no-deps --no-TTY --name openidea-python-1 \
--label openidea.python-probe=true --entrypoint /venv/bin/python web -c print(1)"
);
let target = RunTarget { kind: RunTargetKind::Docker, image: "python:3.12".into(), ..Default::default() };
assert_eq!(
python_probe_args(&target, root, "p", "x").unwrap().join(" "),
"run --name p --label openidea.python-probe=true --entrypoint python3 python:3.12 -c x"
);
let ssh = RunTarget { name: "box".into(), ..Default::default() };
assert!(python_probe_args(&ssh, root, "p", "x").unwrap_err().message.contains("not a container"));
let empty = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() };
assert!(python_probe_args(&empty, root, "p", "x").unwrap_err().message.contains("no service"));
}
/// The container's command line of `c` debugged on `target` (after the image).
#[cfg(unix)]
fn debugged(target: &RunTarget, c: &RunConfig) -> IdeResult<Vec<String>> {
-2
Переглянути файл
@@ -68,8 +68,6 @@ fn profiles_filter_and_preserve_launch_properties() {
assert_eq!(p["http"].command_line_args, "--message \"hello world\"");
assert_eq!(p["http"].working_directory, "work");
assert!(parse_profiles("invalid").is_empty());
// Visual Studio saves launchSettings.json with a byte order mark.
assert_eq!(parse_profiles(&format!("\u{feff}{PROFILES}")).len(), 2);
}
#[test]
-16
Переглянути файл
@@ -69,11 +69,7 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<Pat
/// root, else the Python the project pins for mise / asdf. A configured
/// interpreter whose executable is gone (uninstalled, moved) is skipped like
/// PyCharm's "Invalid" SDKs, so runs fall back instead of failing on a dead path.
/// `None` when the project interpreter is a run target's (see [`crate::container_python`]).
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
if s.languages.python.run_target().is_some() {
return None;
}
setting_path(root, &s.languages.python.interpreter)
.filter(|p| p.is_file())
.or_else(|| project_venv_python(root))
@@ -82,9 +78,6 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
if s.languages.python.run_target().is_some() {
return None;
}
if let Some(python) = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file()) {
return Some(python);
}
@@ -1163,7 +1156,6 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
{
cfg.command = native.to_string_lossy().into_owned();
cfg.args = vec!["--lsp".into(), "--stdio".into()];
cfg.display_name = Some("TypeScript 7".into());
cfg.initialization_options = None;
cfg.tsserver_project = false;
return;
@@ -1307,7 +1299,6 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
}
}
@@ -1485,9 +1476,6 @@ pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a
|| matches!(config.kind.as_str(), "node" | "npm" | "vitest" | "jest" | "mocha")
{
&settings.languages.node.run_target
} else if let Some(target) = settings.languages.python.run_target().filter(|_| is_python_config(config, &name)) {
// The project interpreter is a run target's: Python runs there.
return Some(target);
} else {
return non_empty(&settings.run.default_target);
};
@@ -1500,10 +1488,6 @@ pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a
}
}
fn is_python_config(config: &RunConfig, program: &str) -> bool {
matches!(config.kind.as_str(), "python" | "pytest" | "django-test") || is_python_command(program)
}
/// Whether the configuration's `JAVA_HOME` is the one Settings choose (the Maven runner JRE,
/// the Gradle JVM) rather than a choice of its own: an IDE-generated build-tool configuration —
/// a Maven goal or Gradle task from the tool window — carries the model's `JAVA_HOME`, which
-418
Переглянути файл
@@ -1,418 +0,0 @@
//! A Docker or Compose run target's Python as the project interpreter
//! (`languages.python.interpreter = "target:<id>"`, PyCharm's Docker / Docker Compose
//! interpreter).
//!
//! basedpyright / pyright run on this machine, so the library folders of the
//! container's `sys.path` are copied here once per image (a snapshot) and given to
//! the server as `extraPaths`. Its interpreter is a neutral one: an empty virtualenv
//! the IDE creates, so no local site-packages mix in. Without any local Python the
//! server gets `extraPaths` alone and its bundled typeshed standard library.
//!
//! The snapshot is read-only: packages change in the image (requirements, Dockerfile)
//! and a rebuilt image gets a new snapshot. Taking one runs the docker CLI (`ide-host`).
use crate::apply::merge_init_options;
use crate::exec::{query_in, which};
use crate::libraries::{LibraryGroup, LibraryRoot};
use crate::python::PythonPackage;
use ide_core::{IdeError, IdeResult};
use ide_language::LanguageServerConfig;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Run in the container with `python -c`: prints [`PROBE_MARKER`] and a JSON
/// [`Probe`] (an entrypoint may print more around it).
pub const PROBE: &str = "import json,os,sys,sysconfig\n\
p=sysconfig.get_paths()\n\
print('OPENIDEA-PYTHON '+json.dumps({'version':'%d.%d.%d'%sys.version_info[:3],\
'executable':sys.executable,'stdlib':[p.get('stdlib'),p.get('platstdlib')],\
'path':[x for x in sys.path if x and os.path.isdir(x)]}))";
const PROBE_MARKER: &str = "OPENIDEA-PYTHON ";
/// What [`PROBE`] prints about the container's interpreter.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Probe {
/// `3.12.7`.
pub version: String,
pub executable: String,
/// `stdlib` and `platstdlib` of `sysconfig`.
pub stdlib: Vec<Option<String>>,
/// The folders of `sys.path`, in order.
pub path: Vec<String>,
}
pub fn parse_probe(out: &str) -> IdeResult<Probe> {
let json = out
.lines()
.rev()
.find_map(|l| l.trim().strip_prefix(PROBE_MARKER))
.ok_or_else(|| IdeError::internal(format!("the container's Python printed no paths: {}", out.trim())))?;
serde_json::from_str(json).map_err(|e| IdeError::internal(format!("cannot read the container's Python paths: {e}")))
}
/// The container folders a snapshot copies: the `sys.path` folders outside the standard
/// library (its stubs come with the server) and outside the project, which is here already.
/// A folder inside another one of the list is copied with it.
pub fn folders_to_copy(probe: &Probe, container_root: &str) -> Vec<String> {
let under = |path: &str, dir: &str| {
let dir = dir.trim_end_matches('/');
path == dir || path.starts_with(&format!("{dir}/")) || dir.is_empty()
};
let stdlib: Vec<&str> = probe.stdlib.iter().flatten().map(String::as_str).collect();
// `site-packages` lives inside the standard library's folder.
let packages = |path: &str| path.split('/').any(|c| c == "site-packages" || c == "dist-packages");
let mut out: Vec<String> = Vec::new();
for path in &probe.path {
let path = path.trim_end_matches('/');
if !path.starts_with('/')
|| (stdlib.iter().any(|s| under(path, s)) && !packages(path))
|| (!container_root.trim().is_empty() && under(path, container_root))
|| out.iter().any(|o| under(path, o))
{
continue;
}
out.retain(|o| !under(o, path));
out.push(path.to_owned());
}
out
}
/// Where a snapshot in `dir` keeps its copy of the container folder `container`.
pub fn local_folder(dir: &Path, container: &str) -> PathBuf {
container.split('/').filter(|c| !c.is_empty() && *c != "." && *c != "..").fold(dir.join("fs"), |p, c| p.join(c))
}
/// `snapshot.json` of a snapshot folder.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Snapshot {
/// `sha256:…` of the image the folders were copied from.
pub image: String,
pub probe: Probe,
/// The copied container folders ([`local_folder`] has them here).
pub folders: Vec<String>,
/// The neutral interpreter given to the language server; `None` without a local Python.
pub neutral_python: Option<PathBuf>,
}
const SNAPSHOT_FILE: &str = "snapshot.json";
/// Names the snapshot folder in use, in the target's folder.
const CURRENT_FILE: &str = "current";
/// A target's snapshots: `<cache>/<target>/<image>/`.
pub fn target_dir(cache: &Path, target_id: &str) -> PathBuf {
cache.join(dir_name(target_id))
}
/// The folder name of an image id or target id.
pub fn dir_name(id: &str) -> String {
let id = id.strip_prefix("sha256:").map(|hex| &hex[..hex.len().min(16)]).unwrap_or(id);
let name: String =
id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
if name.is_empty() { "_".into() } else { name }
}
/// The snapshot in use for the target whose folder is `target_dir`, with its folder.
pub fn current(target_dir: &Path) -> Option<(PathBuf, Snapshot)> {
let name = std::fs::read_to_string(target_dir.join(CURRENT_FILE)).ok()?;
let dir = target_dir.join(dir_name(name.trim()));
read(&dir).map(|s| (dir, s))
}
/// The complete snapshot in `dir`, if there is one.
pub fn read(dir: &Path) -> Option<Snapshot> {
serde_json::from_str(&std::fs::read_to_string(dir.join(SNAPSHOT_FILE)).ok()?).ok()
}
/// Writes `snapshot.json` (last: a folder without it is incomplete).
pub fn write(dir: &Path, snapshot: &Snapshot) -> IdeResult<()> {
let json = serde_json::to_string_pretty(snapshot).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(dir.join(SNAPSHOT_FILE), json)?;
Ok(())
}
/// Makes the snapshot of `image` the one in use and removes the target's other snapshots.
pub fn make_current(target_dir: &Path, image: &str) -> IdeResult<()> {
let keep = dir_name(image);
std::fs::write(target_dir.join(CURRENT_FILE), &keep)?;
for entry in std::fs::read_dir(target_dir)?.flatten() {
let path = entry.path();
if path.is_dir()
&& entry.file_name().to_string_lossy() != keep.as_str()
&& let Err(e) = std::fs::remove_dir_all(&path)
{
tracing::warn!("cannot remove the old Python snapshot {}: {e}", path.display());
}
}
Ok(())
}
/// The local folders of a snapshot that exist.
pub fn local_folders(dir: &Path, snapshot: &Snapshot) -> Vec<PathBuf> {
snapshot.folders.iter().map(|f| local_folder(dir, f)).filter(|p| p.is_dir()).collect()
}
/// basedpyright / pyright: the neutral interpreter and the snapshot's folders as
/// `extraPaths`. Without a snapshot yet the server only loses the local interpreter.
pub fn configure_server(cfg: &mut LanguageServerConfig, snapshot: Option<(&Path, &Snapshot)>) {
let section = match cfg.name.as_str() {
"basedpyright" => "basedpyright",
"pyright" => "python",
_ => return,
};
let Some((dir, snapshot)) = snapshot else { return };
if let Some(python) = snapshot.neutral_python.as_deref().filter(|p| p.is_file()) {
merge_init_options(cfg, json!({ "python": { "pythonPath": python.to_string_lossy() } }));
if let Some(venv) = crate::venv_of(python) {
cfg.env.insert("VIRTUAL_ENV".into(), venv.to_string_lossy().into_owned());
}
}
let paths: Vec<String> = local_folders(dir, snapshot).iter().map(|p| p.to_string_lossy().into_owned()).collect();
merge_init_options(cfg, json!({ "settings": { section: { "analysis": { "extraPaths": paths } } } }));
}
/// External Libraries: the snapshot's folders, named after the target.
pub fn library_group(target_name: &str, dir: &Path, snapshot: &Snapshot) -> Option<LibraryGroup> {
let short = snapshot.probe.version.split('.').take(2).collect::<Vec<_>>().join(".");
let roots: Vec<LibraryRoot> = snapshot
.folders
.iter()
.map(|f| (f, local_folder(dir, f)))
.filter(|(_, p)| p.is_dir())
.map(|(f, p)| LibraryRoot { name: f.rsplit('/').next().unwrap_or(f).to_owned(), path: p.into() })
.collect();
(!roots.is_empty()).then(|| LibraryGroup {
name: format!("Python {short} ({target_name})"),
kind: "python".into(),
roots,
})
}
/// The packages of a snapshot, from the `*.dist-info` / `*.egg-info` metadata it copied.
pub fn packages(dir: &Path, snapshot: &Snapshot) -> Vec<PythonPackage> {
let mut out: Vec<PythonPackage> = Vec::new();
for folder in local_folders(dir, snapshot) {
let Ok(entries) = std::fs::read_dir(&folder) else { continue };
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
let metadata = if name.ends_with(".dist-info") {
path.join("METADATA")
} else if name.ends_with(".egg-info") {
if path.is_dir() { path.join("PKG-INFO") } else { path }
} else {
continue;
};
let Some(package) = std::fs::read_to_string(&metadata).ok().and_then(|m| package_of(&m)) else {
continue;
};
if !out.iter().any(|p| p.name.eq_ignore_ascii_case(&package.name)) {
out.push(package);
}
}
}
out.sort_by_key(|p| p.name.to_lowercase());
out
}
fn package_of(metadata: &str) -> Option<PythonPackage> {
let header = |key: &str| {
metadata
.lines()
.take_while(|l| !l.is_empty())
.find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix(':')))
.map(|v| v.trim().to_owned())
};
Some(PythonPackage { name: header("Name")?, version: header("Version").unwrap_or_default() })
}
/// The neutral interpreter for a container Python of `version`: an empty virtualenv in
/// `cache`, made from a local Python of the same minor version when there is one. `None`
/// when this machine has no Python at all.
pub fn neutral_python(cache: &Path, version: &str) -> Option<PathBuf> {
neutral_python_with(cache, version, which)
}
pub(crate) fn neutral_python_with(
cache: &Path,
version: &str,
which: impl Fn(&str) -> Option<PathBuf>,
) -> Option<PathBuf> {
let short = version.split('.').take(2).collect::<Vec<_>>().join(".");
let named = (!short.is_empty()).then(|| format!("python{short}"));
let (base, label) = match named.as_deref().and_then(|n| which(n).map(|p| (p, short.clone()))) {
Some(found) => found,
None => (which("python3").or_else(|| which("python"))?, "default".to_owned()),
};
let venv = cache.join("neutral").join(label);
let python = venv.join(crate::bin_dir()).join(crate::exe("python"));
if python.is_file() && venv.join("pyvenv.cfg").is_file() {
return Some(python);
}
std::fs::create_dir_all(&venv).ok()?;
let location = venv.to_string_lossy().into_owned();
query_in(&base, &["-m", "venv", "--without-pip", &location], None, &[], Duration::from_secs(60));
python.is_file().then_some(python)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::server;
fn probe() -> Probe {
Probe {
version: "3.12.7".into(),
executable: "/usr/local/bin/python3".into(),
stdlib: vec![Some("/usr/local/lib/python3.12".into()), Some("/usr/local/lib/python3.12".into())],
path: [
"/workspaces/app",
"/workspaces/app/src",
"/usr/local/lib/python3.12",
"/usr/local/lib/python3.12/lib-dynload",
"/opt/lib/extra",
"/usr/local/lib/python3.12/site-packages",
"/usr/local/lib/python3.12/site-packages/setuptools/_vendor",
"relative",
]
.map(String::from)
.to_vec(),
}
}
#[test]
fn copies_library_folders_outside_the_stdlib_and_the_project() {
assert_eq!(
folders_to_copy(&probe(), "/workspaces/app"),
["/opt/lib/extra", "/usr/local/lib/python3.12/site-packages"]
);
let mut p = probe();
p.path.insert(0, "/opt/lib/extra/inner".into());
assert_eq!(folders_to_copy(&p, "/workspaces/app")[0], "/opt/lib/extra", "the outer folder replaces the inner");
}
#[test]
fn reads_the_probe_after_other_output() {
let line = format!("{PROBE_MARKER}{}", serde_json::to_string(&probe()).unwrap());
let out = format!("waiting for the entrypoint\n{line}\nbye\n");
assert_eq!(parse_probe(&out).unwrap(), probe());
assert!(parse_probe("python3: not found").unwrap_err().message.contains("not found"));
}
#[test]
fn mirrors_container_paths_and_names_folders() {
let dir = Path::new("snap");
assert_eq!(
local_folder(dir, "/usr/local/lib/../site-packages"),
Path::new("snap").join("fs").join("usr").join("local").join("lib").join("site-packages")
);
assert_eq!(dir_name("sha256:0123456789abcdef0123"), "0123456789abcdef");
assert_eq!(dir_name("target/../x y"), "target____x_y");
}
fn snapshot_in(dir: &Path, neutral: Option<PathBuf>) -> Snapshot {
let snapshot = Snapshot {
image: "sha256:abc".into(),
probe: probe(),
folders: vec!["/usr/local/lib/python3.12/site-packages".into(), "/gone".into()],
neutral_python: neutral,
};
let site = local_folder(dir, &snapshot.folders[0]);
std::fs::create_dir_all(site.join("Django-5.1.3.dist-info")).unwrap();
std::fs::write(
site.join("Django-5.1.3.dist-info/METADATA"),
"Metadata-Version: 2.1\nName: Django\nVersion: 5.1.3\n\nName: not a header\n",
)
.unwrap();
std::fs::create_dir_all(site.join("asgiref-3.8.1.dist-info")).unwrap();
std::fs::write(site.join("asgiref-3.8.1.dist-info/METADATA"), "Name: asgiref\nVersion: 3.8.1\n").unwrap();
std::fs::write(site.join("legacy.egg-info"), "Name: legacy\nVersion: 0.1\n").unwrap();
snapshot
}
#[test]
fn lists_packages_and_library_roots_of_a_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let snapshot = snapshot_in(tmp.path(), None);
let names: Vec<String> =
packages(tmp.path(), &snapshot).into_iter().map(|p| format!("{} {}", p.name, p.version)).collect();
assert_eq!(names, ["asgiref 3.8.1", "Django 5.1.3", "legacy 0.1"]);
let group = library_group("web", tmp.path(), &snapshot).unwrap();
assert_eq!(group.name, "Python 3.12 (web)");
assert_eq!(group.roots.len(), 1, "a folder that was not copied is left out");
assert_eq!(group.roots[0].name, "site-packages");
}
#[test]
fn current_snapshot_switches_and_drops_old_ones() {
let tmp = tempfile::tempdir().unwrap();
let target = target_dir(tmp.path(), "web");
for image in ["sha256:old", "sha256:new"] {
let dir = target.join(dir_name(image));
std::fs::create_dir_all(&dir).unwrap();
write(&dir, &Snapshot { image: image.into(), ..Default::default() }).unwrap();
}
std::fs::create_dir_all(target.join("new.partial")).unwrap();
assert!(current(&target).is_none());
make_current(&target, "sha256:new").unwrap();
let (dir, snapshot) = current(&target).unwrap();
assert_eq!((dir, snapshot.image.as_str()), (target.join("new"), "sha256:new"));
assert!(!target.join("old").exists() && !target.join("new.partial").exists());
}
#[test]
fn basedpyright_gets_the_neutral_interpreter_and_extra_paths() {
let tmp = tempfile::tempdir().unwrap();
let venv = tmp.path().join("neutral");
let python = venv.join(crate::bin_dir()).join(crate::exe("python"));
std::fs::create_dir_all(python.parent().unwrap()).unwrap();
std::fs::write(&python, "").unwrap();
std::fs::write(venv.join("pyvenv.cfg"), "include-system-site-packages = false\n").unwrap();
let snapshot = snapshot_in(tmp.path(), Some(python.clone()));
let site = local_folder(tmp.path(), &snapshot.folders[0]).to_string_lossy().into_owned();
let mut cfg = server("basedpyright");
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
assert_eq!(opts["python"]["pythonPath"], python.to_string_lossy().as_ref());
assert_eq!(opts["settings"]["basedpyright"]["analysis"]["extraPaths"], json!([site]));
assert_eq!(cfg.env["VIRTUAL_ENV"], venv.to_string_lossy().as_ref());
let mut cfg = server("pyright");
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
assert_eq!(opts["settings"]["python"]["analysis"]["extraPaths"], json!([site]));
let mut cfg = server("ruff");
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
assert!(cfg.initialization_options.is_none());
}
#[test]
fn without_a_local_python_only_extra_paths_reach_the_server() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(neutral_python_with(tmp.path(), "3.12.7", |_| None), None);
let snapshot = snapshot_in(tmp.path(), None);
let mut cfg = server("basedpyright");
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
assert!(opts.get("python").is_none(), "no pythonPath: the server falls back to its bundled typeshed");
assert_eq!(opts["settings"]["basedpyright"]["analysis"]["extraPaths"].as_array().unwrap().len(), 1);
assert!(!cfg.env.contains_key("VIRTUAL_ENV"));
}
// A real `python3 -m venv`: runs where a Python is installed (the dev container, CI).
#[test]
fn neutral_python_is_an_empty_virtualenv() {
let Some(base) = which("python3") else { return };
let tmp = tempfile::tempdir().unwrap();
let which = |name: &str| (name == "python3").then(|| base.clone());
let python = neutral_python_with(tmp.path(), "0.0.1", which).unwrap();
assert!(python.starts_with(tmp.path().join("neutral").join("default")));
let cfg = std::fs::read_to_string(crate::venv_of(&python).unwrap().join("pyvenv.cfg")).unwrap();
assert!(cfg.contains("include-system-site-packages = false"));
assert_eq!(neutral_python_with(tmp.path(), "0.0.1", which), Some(python), "reused");
}
}
-1
Переглянути файл
@@ -9,7 +9,6 @@ mod angular;
pub mod apply;
pub mod arduino;
mod astro;
pub mod container_python;
mod discover;
mod exec;
pub mod go;
-4
Переглянути файл
@@ -254,10 +254,6 @@ fn module_settings(root: &Path, settings: &Settings) -> Settings {
&mut l.go.gopath,
&mut l.java.jdk,
] {
// `target:<id>` names a run target, not a path.
if value.trim().starts_with(ide_core::settings::PythonSettings::TARGET_PREFIX) {
continue;
}
if let Some(path) = crate::apply::setting_path(root, value) {
*value = path.to_string_lossy().into_owned();
}
+1 -45
Переглянути файл
@@ -21,7 +21,7 @@ fn fake_venv(root: &Path, name: &str) -> std::path::PathBuf {
py
}
pub(crate) fn server(name: &str) -> LanguageServerConfig {
fn server(name: &str) -> LanguageServerConfig {
LanguageServerConfig {
name: name.into(),
command: name.into(),
@@ -42,7 +42,6 @@ pub(crate) fn server(name: &str) -> LanguageServerConfig {
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
}
}
@@ -189,48 +188,6 @@ fn pyright_gets_python_path_and_venv_env() {
assert!(cfg.env["PATH"].starts_with(&*tmp.path().join(".venv").join(bin_dir()).to_string_lossy()));
}
#[test]
fn a_run_target_interpreter_hides_local_pythons() {
let tmp = tempfile::tempdir().unwrap();
fake_venv(tmp.path(), ".venv");
let mut s = Settings::default();
s.languages.python.interpreter = "target:web".into();
assert_eq!(python_interpreter(tmp.path(), &s), None, "the project virtualenv is not the interpreter");
assert_eq!(terminal_activation(tmp.path(), &s, "bash"), None);
assert!(!tool_install_environment(tmp.path(), &s).commands.contains_key("python"));
let mut cfg = server("basedpyright");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let opts = cfg.initialization_options.map(|o| serde_json::to_value(o).unwrap());
assert!(opts.and_then(|o| o.pointer("/python/pythonPath").cloned()).is_none());
assert!(!cfg.env.contains_key("VIRTUAL_ENV"));
}
#[test]
fn python_configurations_run_on_the_interpreters_target() {
let tmp = tempfile::tempdir().unwrap();
fake_venv(tmp.path(), ".venv");
let mut s = Settings::default();
s.languages.python.interpreter = "target:web".into();
s.run.default_target = "box".into();
let mut own = run("python", "python3", &["main.py"]);
own.extras.target = Some(String::new());
let mut configs = vec![
run("python", "python3", &["manage.py", "runserver"]),
run("pytest", "python3", &["-m", "pytest"]),
run("npm", "npm", &["run", "dev"]),
own,
];
apply_to_run_configs(&mut configs, tmp.path(), &s);
assert_eq!(configs[0].extras.target.as_deref(), Some("web"));
assert_eq!(configs[0].command, "python3", "the container resolves the interpreter");
assert!(!configs[0].env.contains_key("VIRTUAL_ENV"));
assert_eq!(configs[1].extras.target.as_deref(), Some("web"));
assert_eq!(configs[2].extras.target.as_deref(), Some("box"), "other languages keep the default target");
assert_eq!(configs[3].extras.target.as_deref(), Some(""), "a configuration's own choice wins");
let derived = without_derived(&configs[0], tmp.path(), &s);
assert_eq!(derived.extras.target, None, "the target is derived from the interpreter");
}
#[test]
fn jdtls_vm_args() {
let tmp = tempfile::tempdir().unwrap();
@@ -1430,7 +1387,6 @@ fn typescript_7_runs_its_own_language_server() {
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), native, "{name}");
assert_eq!(cfg.args, ["--lsp", "--stdio"]);
assert_eq!(cfg.display_name.as_deref(), Some("TypeScript 7"));
assert!(cfg.initialization_options.is_none());
}
// Picked as a directory (its `lib`, as for older versions), too.
+1 -12
Переглянути файл
@@ -50,12 +50,7 @@ Agents are unavailable in a project you have not marked as trusted.
- **Local checkout**: the agent edits your files directly.
</Step>
<Step title="Pick the agent and its settings">
Choose the agent, and its model, mode and other options when it offers them. Cached models appear immediately; the selected agent's catalog refreshes in the background when it is more than 15 minutes old, without sending a prompt or attaching project MCP servers.
After changing the agent CLI or account, open the model picker and choose **Refresh models**. Refreshes share one lookup and have a 30-second cooldown after completion, including failures. Cached choices remain usable while refreshing or after a failure.
Available models depend on the installed agent and account. If a selected model is removed, choose another before starting. Refreshing does not change running tasks.
Choose the agent, and its model, mode and other options when it offers them. Models and modes appear after the agent's first task.
</Step>
<Step title="Send">
The task opens as an editor tab and starts.
@@ -72,12 +67,6 @@ The task tab shows the conversation: your messages, the agent's replies, its rea
On your own messages you can **Edit** (the messages after it are replaced; files already changed stay as they are) or **Retract Message**. On replies you can **Copy Markdown**, **Quote Reply**, **Regenerate Response** and **Branch Conversation from Here**, which starts a new task from that point.
**Focus Agent Input** defaults to `⌘L` on macOS and `Ctrl+L` on Windows / Linux in every built-in keymap. When focus is inside your writable task conversation, in either an editor tab or the tool window, it returns focus to the message input without changing your draft, caret position or selection—even when pressed repeatedly. It does not send a message or jump through message history.
The shortcut does not apply to archived tasks, subagent conversations or the New Task page, and does not take focus from open popups or dialogs. Outside the task conversation, the keys keep their usual editor or terminal behavior. To change or remove the binding, open **Settings › Keymap › Agents › Focus Agent Input**.
Right-click a link and choose **Copy Link Address** to copy its URL; **Copy** copies the selected text.
### Permissions
When an agent wants to run a command or call a tool that needs approval, the task shows the request and is listed as **Needs Input**. Choose one of the options the agent offers, such as **Allow Once**, **Allow for This Session**, **Always Allow**, **Decline**, **Always Decline** or **Decline and Stop**. Many agents also have a mode picker that approves edits or everything automatically; use it with care.
-2
Переглянути файл
@@ -107,8 +107,6 @@ To format on every save, enable **Reformat code** (and optionally **Optimize imp
Tab placement, the tab limit and what happens when it is reached, the preview tab, sorting, and which tab is activated after one closes are set in **Settings › Editor › General › Editor Tabs**.
With tabs placed left or right, the tab column is as wide as its longest name, up to a limit. Drag the column's inner edge (or focus it and press `←` / `→`) to change the limit; it applies to every editor group and is remembered.
## Split editors
| Action | macOS | Windows / Linux |
-3
Переглянути файл
@@ -96,7 +96,4 @@ Container target options:
| **Run options** | Extra `docker run` options, for example `--network host`. |
| **Run as the image's user, not the project folder's owner (Linux)** | On Linux the container runs as the owner of the project folder by default, so the files it writes stay yours. Tick this to keep the image's user. |
A container target can also be the project's Python interpreter, so the editor resolves the packages installed in the
image; see [Python](/en/languages/python#a-docker-or-compose-run-target-as-the-interpreter).
See [Run configurations](/en/guide/run) for run targets in general, including SSH targets.
-11
Переглянути файл
@@ -49,17 +49,6 @@ The selected interpreter is passed to basedpyright and Pyright (`python.pythonPa
The **Python Interpreter** item in the status bar shows the current interpreter. Click it to switch, or to open **Add New Interpreter…** and **Interpreter Settings…**.
### A Docker or Compose run target as the interpreter
When the project's dependencies are installed only in an image, choose a Docker or Docker Compose [run target](/en/guide/remote/containers#docker-and-docker-compose-run-targets) as the interpreter: the interpreter list and the status bar item show those targets next to the local interpreters.
- **Editor.** The IDE runs the target's Python once in a new container (the image's entrypoint and the service's dependencies are skipped) and copies the folders of its `sys.path` outside the standard library and the project, such as `site-packages`, to its cache with `docker cp`. basedpyright and Pyright get the copies as `extraPaths`, with an empty virtualenv the IDE creates as their interpreter, so local packages don't mix in. Without any Python on this machine they get `extraPaths` alone and use their bundled standard library stubs. The copy is shown under **External Libraries**.
- **Rebuilds.** The copy belongs to the image: when the project opens or the interpreter is chosen, a rebuilt image gets a new copy. The refresh button on the packages list copies again.
- **Run.** Python run configurations without a run target of their own run on that target.
- **Packages.** The list comes from the copy and is read-only: change the image or its requirements, rebuild it, and copy again. Nothing is installed into the container.
A `pyrightconfig.json` or a `[tool.basedpyright]` / `[tool.pyright]` section with its own `extraPaths` replaces the copied paths. The interpreter's Python version is the empty virtualenv's, from a local Python of the same version when there is one.
### Creating an environment
On **Settings › Languages & Frameworks › Python Interpreter**, choose **Add Python Interpreter** and pick the **Interpreter type**:
+1 -12
Переглянути файл
@@ -50,12 +50,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
- **当前工作区**:agent 直接修改你的文件。
</Step>
<Step title="选择 agent 及其设置">
选择 agent,以及它提供的模型、模式等选项。缓存的模型会立即显示;所选 agent 的模型列表超过 15 分钟未更新时,会在后台刷新,不发送提示词,也不接入项目 MCP 服务器。
更改 agent CLI 或账号后,打开模型选择器并单击 **刷新模型**。并发刷新会共用一次查询;每次查询完成后(包括失败)都有 30 秒冷却时间。刷新中或刷新失败后,缓存选项仍可使用。
可用模型取决于已安装的 agent 和账号。如果已选模型被移除,需要先选择其他模型才能开始任务。刷新不会更改正在运行的任务。
选择 agent,以及它提供的模型、模式等选项。这些选项会在该 agent 跑完第一个任务后出现。
</Step>
<Step title="发送">
任务会在编辑器标签页中打开并开始运行。
@@ -72,12 +67,6 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
在你自己的消息上可以 **编辑**(其后的消息会被替换,已经改动的文件保持不变)或 **撤回消息**。在回复上可以 **复制 Markdown**、**引用回复**、**重新生成回复**,以及 **从此处创建分支对话**,从这一点开始一个新任务。
所有内置按键映射中,**聚焦 Agent 输入框** 的默认快捷键都是 macOS 上的 `⌘L`、Windows / Linux 上的 `Ctrl+L`。焦点位于你自己的可输入任务对话中时,无论对话显示在编辑器标签页还是工具窗口中,它都会将焦点移回消息输入框,保留草稿、光标位置和选区;重复按下也不会改变这些内容。它不会发送消息,也不会跳转消息历史。
此快捷键不适用于已归档任务、子 agent 对话或“新建任务”页面,也不会从打开的弹出窗口或对话框中抢走焦点。在任务对话之外,按键保留原有的编辑器或终端行为。要修改或移除绑定,请打开 **设置 › 按键映射 › 智能体 › 聚焦 Agent 输入框**。
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
### 权限
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
-2
Переглянути файл
@@ -107,8 +107,6 @@ description: "多光标与选区、行编辑、剪贴板历史、代码折叠、
标签页的位置、数量上限以及达到上限后的处理方式、预览标签页、排序方式,以及关闭标签页后激活哪一个,都在 **设置 › 编辑器 › 常规 › 编辑器标签页** 中设置。
标签页放在左侧或右侧时,标签页列的宽度随最长的名称变化,但不超过一个上限。拖动该列靠近编辑器的边缘(或聚焦后按 `←` / `→`)可以调整上限;调整对所有编辑器组生效,并会被记住。
## 拆分编辑器
| 操作 | macOS | Windows / Linux |
-2
Переглянути файл
@@ -78,6 +78,4 @@ OpenIDEA 安装的语言服务器和调试器会装进容器,位于容器用
| **运行选项** | 额外的 `docker run` 参数,例如 `--network host`。 |
| **以镜像的用户运行,而不是项目文件夹的所有者(Linux)** | 在 Linux 上,容器默认以项目文件夹所有者的身份运行,写出的文件仍归你所有。勾选后保留镜像自带的用户。 |
容器类目标也可以作为项目的 Python 解释器,让编辑器识别镜像中安装的软件包,见 [Python](/zh/languages/python#以-docker-或-compose-运行目标作为解释器)。
运行目标的整体说明(包括 SSH 目标)见[运行配置](/zh/guide/run)。
-11
Переглянути файл
@@ -49,17 +49,6 @@ Ruff 只为位于 Ruff 配置之下的文件启动:`ruff.toml`、`.ruff.toml`
状态栏上的 **Python 解释器** 显示当前解释器,点击可以切换,也可以打开 **添加新的解释器…** 和 **解释器设置…**。
### 以 Docker 或 Compose 运行目标作为解释器
如果项目的依赖只安装在镜像里,可以把 Docker 或 Docker Compose [运行目标](/zh/guide/remote/containers#docker-和-docker-compose-运行目标)选为解释器:解释器列表和状态栏项会把这些目标列在本地解释器旁边。
- **编辑器。** IDE 在新容器中运行一次目标的 Python(跳过镜像的入口点和服务的依赖),然后用 `docker cp` 把其 `sys.path` 中标准库和项目以外的目录(例如 `site-packages`)复制到缓存。basedpyright 和 Pyright 以 `extraPaths` 的形式获得这些副本,解释器则是 IDE 创建的空虚拟环境,因此不会混入本地软件包。本机完全没有 Python 时,只传 `extraPaths`,并使用它们自带的标准库存根。副本显示在 **外部库** 下。
- **重新构建。** 副本与镜像对应:项目打开或选择解释器时,重新构建过的镜像会得到新的副本。软件包列表上的刷新按钮会重新复制。
- **运行。** 没有自己运行目标的 Python 运行配置会在该目标上运行。
- **软件包。** 列表来自副本,并且只读:请修改镜像或其依赖要求,重新构建,再重新复制。不会向容器中安装任何内容。
如果 `pyrightconfig.json` 或 `[tool.basedpyright]` / `[tool.pyright]` 设置了自己的 `extraPaths`,它们会取代复制的路径。解释器的 Python 版本取自空虚拟环境;本机有相同版本的 Python 时会用它来创建。
### 创建环境
在 **设置 › 语言和框架 › Python 解释器** 中选择 **添加 Python 解释器**,再选 **解释器类型**:
-379
Переглянути файл
@@ -1,379 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import type { AgentEntry, AgentTask } from "../src/bindings";
import { expect, openFromTree, test } from "./fixtures";
type Placement = "editor" | "toolWindow";
const firstTask: AgentTask = {
id: "focus-first",
title: "First focus 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: [],
};
const secondTask: AgentTask = { ...firstTask, id: "focus-second", title: "Second focus task" };
const draft = "draft keep this unsent";
const reboundShortcut = "Control+Meta+Shift+J";
const subagent: AgentEntry = {
type: "subagent",
id: "focus-child",
title: "Inspect focus behavior",
description: null,
status: "done",
error: null,
prompt: null,
reply: null,
tools: 0,
activity: null,
startedMs: null,
endedMs: null,
cancellable: false,
};
async function seedTasks(page: Page, placement: Placement, status: AgentTask["status"] = "ready") {
const entries: AgentEntry[] = Array.from({ length: 24 }, (_, turn): AgentEntry[] => [
{ type: "user", text: `Request ${turn}`, attachments: [] },
{ type: "agent", text: `Reply ${turn}` },
]).flat();
await page.evaluate(
async ({ firstTask, secondTask, entries, subagent, placement, status }) => {
// 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: [] });
document.documentElement.dataset.agentPromptCount = "0";
commands.agentPrompt = async () => {
const root = document.documentElement;
root.dataset.agentPromptCount = String(Number(root.dataset.agentPromptCount) + 1);
return { status: "ok", data: null };
};
store.useAgents.setState({
loaded: true,
error: null,
providers: [],
tasks: new Map([
[firstTask.id, { ...firstTask, status }],
[secondTask.id, secondTask],
]),
entries: new Map([
[firstTask.id, [...entries, subagent]],
[secondTask.id, entries],
[`${firstTask.id}/${subagent.id}`, [{ type: "agent", text: "Subagent findings" }]],
]),
composers: new Map(),
unread: new Set(),
docked: null,
homeOpen: false,
sessionLocation: placement,
});
store.openTask(firstTask.id);
},
{ firstTask, secondTask, entries, subagent, placement, status },
);
await expect(messageBox(page)).toBeVisible();
await page.evaluate(() => document.fonts.ready);
}
function messageBox(page: Page) {
return page.getByRole("textbox", { name: "Message", exact: true }).filter({ visible: true });
}
function conversation(page: Page) {
return page.locator("[class*='_stream_']:visible");
}
// Read the browser selection, not the store's focus request or a registry entry.
async function selection(box: Locator) {
return box.evaluate((element) => {
const selected = window.getSelection();
if (!selected?.anchorNode || !selected.focusNode) return null;
if (!element.contains(selected.anchorNode) || !element.contains(selected.focusNode)) return null;
const offset = (node: Node, at: number) => {
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(node, at);
return range.toString().length;
};
return {
anchor: offset(selected.anchorNode, selected.anchorOffset),
head: offset(selected.focusNode, selected.focusOffset),
text: selected.toString(),
};
});
}
async function selectDraft(box: Locator, range: boolean) {
await box.fill(draft);
await box.press("Home");
for (let index = 0; index < 6; index++) await box.press("ArrowRight");
if (range) {
for (let index = 0; index < 4; index++) await box.press("Shift+ArrowRight");
}
const expected = { anchor: 6, head: range ? 10 : 6, text: range ? "keep" : "" };
await expect.poll(() => selection(box)).toEqual(expected);
return expected;
}
for (const placement of ["editor", "toolWindow"] as const) {
for (const status of ["ready", "running"] as const) {
test(`${placement}: default shortcut restores ${status} task input without changing draft, selection or history`, async ({
workbench: page,
}) => {
await seedTasks(page, placement, status);
const box = messageBox(page);
const history = conversation(page);
for (const range of [false, true]) {
const expected = await selectDraft(box, range);
await history.click({ position: { x: 12, y: 20 } });
await expect(history).toBeFocused();
await history.press("Home");
await expect.poll(() => history.evaluate((element) => element.scrollTop)).toBe(0);
for (let repeat = 0; repeat < 3; repeat++) {
await page.keyboard.press("Meta+L");
await expect(box).toBeFocused();
await expect(box).toHaveText(draft);
await expect.poll(() => selection(box)).toEqual(expected);
await expect.poll(() => history.evaluate((element) => element.scrollTop)).toBe(0);
}
}
await expect(page.getByRole("dialog", { name: "Go to Line:Column", exact: true })).toHaveCount(0);
await expect(page.locator("html")).toHaveAttribute("data-agent-prompt-count", "0");
});
}
test(`${placement}: focuses the shown task rather than another kept-alive composer`, async ({ workbench: page }) => {
await seedTasks(page, placement);
await messageBox(page).fill("first task's unsent draft");
await page.evaluate(async (id) => {
// Load the workbench store inside the browser, not the test runner.
const storePath = "/src/features/agents/store.ts";
(await import(storePath)).openTask(id);
}, secondTask.id);
await messageBox(page).fill("second task's unsent draft");
if (placement === "editor") {
await expect(page.getByRole("textbox", { name: "Message", exact: true, includeHidden: true })).toHaveCount(2);
await page.getByRole("tab", { name: firstTask.title, exact: true }).click();
} else {
await page.getByRole("button", { name: "Agent sessions", exact: true }).click();
await page.getByRole("tree", { name: "Agent tasks" }).getByText(firstTask.title, { exact: true }).dblclick();
}
await expect(messageBox(page)).toHaveText("first task's unsent draft");
await conversation(page).click({ position: { x: 12, y: 20 } });
await page.keyboard.press("Meta+L");
await expect(messageBox(page)).toBeFocused();
await expect(messageBox(page)).toHaveText("first task's unsent draft");
if (placement === "editor") {
await page.getByRole("tab", { name: secondTask.title, exact: true }).click();
} else {
await page.getByRole("button", { name: "Agent sessions", exact: true }).click();
await page.getByRole("tree", { name: "Agent tasks" }).getByText(secondTask.title, { exact: true }).dblclick();
}
await conversation(page).click({ position: { x: 12, y: 20 } });
await page.keyboard.press("Meta+L");
await expect(messageBox(page)).toBeFocused();
await expect(messageBox(page)).toHaveText("second task's unsent draft");
await expect(page.locator("html")).toHaveAttribute("data-agent-prompt-count", "0");
});
}
test("Settings → Keymap rebinds Focus Agent Input and removes its default shortcut", async ({ workbench: page }) => {
await seedTasks(page, "editor");
const box = messageBox(page);
const expected = await selectDraft(box, true);
await page.keyboard.press("Meta+,");
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
await settings.getByRole("treeitem", { name: "Keymap", exact: true }).click();
await settings.getByRole("textbox", { name: "Search actions", exact: true }).fill("Focus Agent Input");
const row = settings.getByRole("tree", { name: "Actions" }).getByRole("treeitem", { name: /Focus Agent Input/ });
await row.dblclick();
await page.getByRole("menuitem", { name: "Add Keyboard Shortcut", exact: true }).click();
const recorder = page.getByRole("dialog", { name: "Keyboard Shortcut: Focus Agent Input", exact: true });
await recorder.getByRole("textbox", { name: "First stroke" }).press(reboundShortcut);
await recorder.getByRole("button", { name: "OK", exact: true }).click();
await expect(recorder).toBeHidden();
await row.click({ button: "right" });
await page.getByRole("menuitem", { name: "Remove ⌘L", exact: true }).click();
await settings.getByRole("button", { name: "OK", exact: true }).click();
await expect(settings).toBeHidden();
const history = conversation(page);
await history.click({ position: { x: 12, y: 20 } });
await expect(history).toBeFocused();
await page.keyboard.press("Meta+L");
await expect(history).toBeFocused();
await expect(box).not.toBeFocused();
await page.keyboard.press(reboundShortcut);
await expect(box).toBeFocused();
await expect(box).toHaveText(draft);
await expect.poll(() => selection(box)).toEqual(expected);
await expect(page.locator("html")).toHaveAttribute("data-agent-prompt-count", "0");
});
test("modals and conversation popups keep keyboard focus", async ({ workbench: page }) => {
await seedTasks(page, "editor");
const box = messageBox(page);
await box.fill(draft);
await page.keyboard.press("Meta+,");
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
const search = settings.getByRole("textbox", { name: "Search settings", exact: true });
await search.fill("keymap");
await page.keyboard.press("Meta+L");
await expect(search).toBeFocused();
await expect(search).toHaveValue("keymap");
await expect(box).not.toBeFocused();
await settings.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(settings).toBeHidden();
const reply = conversation(page).getByText("Reply 0", { exact: true });
await reply.dblclick();
await reply.click({ button: "right" });
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
const firstItem = menu.getByRole("menuitem").first();
await expect(firstItem).toBeFocused();
await page.keyboard.press("Meta+L");
await expect(menu).toBeVisible();
await expect(firstItem).toBeFocused();
await expect(box).not.toBeFocused();
await page.keyboard.press("Escape");
await expect(box).toHaveText(draft);
});
test("archived tasks do not focus their disabled composer", async ({ workbench: page }) => {
await seedTasks(page, "editor", "archived");
await expect(
page.getByText("This task is archived. Unarchive it to continue the conversation.", { exact: true }),
).toBeVisible();
const history = conversation(page);
await history.click({ position: { x: 12, y: 20 } });
await expect(history).toBeFocused();
await page.keyboard.press("Meta+L");
await expect(history).toBeFocused();
await expect(messageBox(page)).not.toBeFocused();
});
test("subagent conversations do not focus the hidden parent composer", async ({ workbench: page }) => {
await seedTasks(page, "editor");
await messageBox(page).fill(draft);
await page.getByTitle("Open the subagent's conversation", { exact: true }).click();
await expect(page.getByRole("navigation", { name: "Subagent", exact: true })).toBeVisible();
const history = conversation(page);
await history.click({ position: { x: 12, y: 20 } });
await expect(history).toBeFocused();
await page.keyboard.press("Meta+L");
await expect(history).toBeFocused();
await expect(messageBox(page)).toHaveCount(0);
await page
.getByRole("navigation", { name: "Subagent", exact: true })
.getByRole("button", { name: firstTask.title, exact: true })
.click();
await expect(messageBox(page)).toHaveText(draft);
await conversation(page).click({ position: { x: 12, y: 20 } });
await page.keyboard.press("Meta+L");
await expect(messageBox(page)).toBeFocused();
});
test("editor Go to Line stays available while a docked agent conversation is visible", async ({ workbench: page }) => {
const editor = await openFromTree(page, "README.md");
await seedTasks(page, "toolWindow");
await messageBox(page).fill(draft);
const content = editor.locator(".cm-content");
await content.click();
await page.keyboard.press("Meta+L");
const dialog = page.getByRole("dialog", { name: "Go to Line:Column", exact: true });
const line = dialog.getByRole("textbox", { name: "Line and column", exact: true });
await expect(line).toBeFocused();
await expect(messageBox(page)).not.toBeFocused();
await line.fill("2:1");
await dialog.getByRole("button", { name: "OK", exact: true }).click();
await expect(dialog).toBeHidden();
await expect(content).toBeFocused();
await expect(messageBox(page)).toHaveText(draft);
});
for (const preset of ["intellij-windows", "vscode-windows", "eclipse-windows"]) {
test(`${preset} uses Ctrl+L to focus the task message`, async ({ workbench: page }) => {
await seedTasks(page, "editor");
const box = messageBox(page);
const expected = await selectDraft(box, true);
await page.evaluate(async (preset) => {
// Load the keymap in the browser where keyboard dispatch runs.
const keymapPath = "/src/platform/keymap.ts";
(await import(keymapPath)).applyKeymapSettings(preset, {});
}, preset);
const history = conversation(page);
await history.click({ position: { x: 12, y: 20 } });
await expect(history).toBeFocused();
await page.keyboard.press("Control+L");
await expect(box).toBeFocused();
await expect(box).toHaveText(draft);
await expect.poll(() => selection(box)).toEqual(expected);
await expect(page.locator("html")).toHaveAttribute("data-agent-prompt-count", "0");
});
}
test("Find Action focuses the current task input after closing its popup", async ({ workbench: page }) => {
await seedTasks(page, "editor");
const box = messageBox(page);
const expected = await selectDraft(box, true);
await conversation(page).click({ position: { x: 12, y: 20 } });
await page.keyboard.press("Meta+Shift+A");
const search = page.getByRole("dialog", { name: "Search Everywhere", exact: true });
const query = search.getByRole("combobox");
await query.fill("Focus Agent Input");
await expect(search.getByRole("option", { name: /Focus Agent Input/ })).toBeVisible();
await query.press("Enter");
await expect(search).toBeHidden();
await expect(box).toBeFocused();
await expect(box).toHaveText(draft);
await expect.poll(() => selection(box)).toEqual(expected);
});
test("docked Changes view returns to the task input without losing its selection", async ({ workbench: page }) => {
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 page.getByRole("button", { name: /1 changed file/ }).click();
const changes = page.getByRole("complementary", { name: "Changes", exact: true });
await expect(changes).toBeVisible();
await expect(box).toHaveCount(0);
await changes.getByRole("button", { name: "Back to conversation", exact: true }).focus();
await page.keyboard.press("Meta+L");
await expect(changes).toBeHidden();
await expect(box).toBeFocused();
await expect(box).toHaveText(draft);
await expect.poll(() => selection(box)).toEqual(expected);
});
-31
Переглянути файл
@@ -198,37 +198,6 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
});
test("copies a reply link's address from its context menu and keeps Copy for the selected text", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const url = "https://example.com/pull/42?tab=files#diff";
const reply = await openReply(page, `[**Review changes**](${url})\n\nOrdinary reply text`);
const link = reply.getByRole("link", { name: "Review changes" });
await link.locator("strong").click({ button: "right" });
await page.getByRole("menuitem", { name: "Copy Link Address" }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(url);
await link.evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
});
await link.click({ button: "right" });
await page
.getByRole("menuitem", { name: /^Copy\b/ })
.first()
.click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("Review changes");
const paragraph = reply.getByText("Ordinary reply text", { exact: true });
await paragraph.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Copy Link Address" })).toHaveCount(0);
await page.keyboard.press("Escape");
});
test("keeps latest reply actions visible and quotes into the draft without shifting content", async ({ page }) => {
const reply = await openReply(page, REPLY);
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
-35
Переглянути файл
@@ -285,38 +285,3 @@ test("vertical tabs leave a narrow editor group most of its width", async ({ wor
expect(contentBox!.width).toBeGreaterThanOrEqual(paneBox!.width * 0.6 - 1);
}
});
test("dragging the vertical tab column's edge resizes it and the width is kept", async ({ workbench: page }) => {
await openFromTree(page, "settings.gradle.kts");
const placeLeft = () =>
page.evaluate(async () => {
const projectModule = "/src/platform/project.ts";
const schemaModule = "/src/features/settings/model/schema.ts";
const [{ useProject }, { DEFAULT_SETTINGS }] = await Promise.all([
import(/* @vite-ignore */ projectModule),
import(/* @vite-ignore */ schemaModule),
]);
const base = useProject.getState().settings ?? DEFAULT_SETTINGS;
useProject.setState({
settings: { ...base, editor: { ...base.editor, tabs: { ...base.editor.tabs, placement: "left" } } },
});
});
await placeLeft();
const strip = page.getByRole("tablist", { name: "Editor tabs" });
const handle = page.getByRole("separator", { name: "Resize editor tabs" });
const before = (await strip.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 - 60, box.y + box.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(async () => (await strip.boundingBox())!.width).toBeLessThan(before - 40);
const narrowed = (await strip.boundingBox())!.width;
// The next session starts with the dragged width.
await page.reload();
await openFromTree(page, "settings.gradle.kts");
await placeLeft();
await expect.poll(async () => Math.round((await strip.boundingBox())!.width)).toBe(Math.round(narrowed));
});
-14
Переглянути файл
@@ -172,20 +172,6 @@ test("accepting a deleted version removes the file before staging", async ({ pag
]);
});
test("a long side shows only the lines it changed against the base", async ({ page }) => {
// Edits spread over a long file make the default diff give up and mark everything between them.
const base = Array.from({ length: 2000 }, (_, i) => `"node_modules/pkg-${i}": "1.0.${i}",`);
const bump = (at: number) => lines(...base.map((l, i) => (i % 100 === at ? l.replace('"1.0.', '"2.0.') : l)));
await mountMerge(page, [lines(...base), bump(3), bump(50)]);
for (const [name, line] of [
["Your version", 3],
["Their version", 50],
] as const) {
const deleted = page.getByRole("region", { name }).locator(".cm-deletedChunk").first().locator(".cm-deletedLine");
await expect(deleted).toHaveText([base[line]!]);
}
});
test("the result starts as the base and every change waits for a decision", async ({ page }) => {
await mountMerge(page);
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
-102
Переглянути файл
@@ -1,102 +0,0 @@
import { expect, stubDesktop, test } from "./fixtures";
test.use({ video: "on" });
test("offers file templates for the selected workspace member", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const markers: Record<string, string> = {
"/work/api": "go.mod",
"/work/web": "pom.xml",
"/work/library": "pom.xml",
};
window.__ipc.handlers.fs_read_dir = ({ path }) => {
const name = markers[String(path)];
return name ? [{ name, path: `${path}/${name}`, kind: "file", size: 0, mtimeMs: 0 }] : [];
};
window.__ipc.handlers.run_configs = () => [
{
id: "workspace:web:tomcat",
name: "Web / Tomcat",
kind: "tomcat",
command: "catalina",
args: ["run"],
cwd: "/work/web",
module: "/work/web",
env: {},
detected: true,
isTest: false,
debugAdapter: null,
},
];
window.__ipc.handlers.project_roots = () => ({ folders: [] });
const module = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ module);
const current = useProject.getState().project;
useProject.setState({
project: {
...current,
name: "Workspace",
root: "/workspace",
workspace: { version: 1, name: "Workspace", projects: Object.keys(markers) },
},
});
});
const newMenu = async (path: string) => {
await page.locator(`[data-project-path="${path}"]`).click({ button: "right" });
await page.getByRole("menuitem", { name: "New", exact: true }).hover();
await expect(page.getByRole("menuitem", { name: "From Template…", exact: true })).toBeVisible();
};
await newMenu("/work/api");
await expect(page.getByRole("menuitem", { name: "Go File", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("go-new-menu.png"), animations: "disabled" });
await expect(page.getByRole("menuitem", { name: "Go Module", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "JSON File", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Java Class", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Tomcat Context Descriptor", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await newMenu("/work/web");
await expect(page.getByRole("menuitem", { name: "Java Class", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Tomcat Context Descriptor", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Go File", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "TypeScript File", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("tomcat-new-menu.png"), animations: "disabled" });
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await page.locator('[data-project-path="/work/library"]').click();
await page.keyboard.press("Meta+n");
const popup = page.getByRole("dialog", { name: "New", exact: true });
await expect(popup.getByText("Java Class", { exact: true })).toBeVisible();
await expect(popup.getByText("Tomcat Context Descriptor", { exact: true })).toHaveCount(0);
await expect(popup.getByText("Go File", { exact: true })).toHaveCount(0);
});
test("uses the single project's type in the keyboard New popup", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
window.__ipc.handlers.fs_read_dir = ({ path }) =>
path === "/work/script" ? [{ name: "pyproject.toml", path: "/work/script/pyproject.toml", kind: "file" }] : [];
window.__ipc.handlers.run_configs = () => [];
window.__ipc.handlers.project_roots = () => ({ folders: [] });
const module = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ module);
useProject.setState({
project: { ...useProject.getState().project, name: "script", root: "/work/script" },
});
});
await page.locator('[data-project-path="/work/script"]').click();
await page.keyboard.press("Meta+n");
const popup = page.getByRole("dialog", { name: "New", exact: true });
await expect(popup.getByText("Python File", { exact: true })).toBeVisible();
await expect(popup.getByText("Go File", { exact: true })).toHaveCount(0);
await expect(popup.getByText("Tomcat Context Descriptor", { exact: true })).toHaveCount(0);
await popup.getByRole("textbox", { name: "Search for file type" }).fill("from template");
await popup.getByText("From Template…", { exact: true }).click();
await expect(
page.getByRole("dialog", { name: "New File from Template" }).getByText("Go File", { exact: true }),
).toBeVisible();
});
+2 -14
Переглянути файл
@@ -4,8 +4,8 @@ use crate::state::AppState;
use ide_agent::registry::AgentRegistry;
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentModelCatalog, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput,
ExternalSession, McpServer, McpServerInput, NewAgentTask,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpServer, McpServerInput, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath, RequestId};
use ide_host::ops::agent;
@@ -37,18 +37,6 @@ pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult<
agent::providers(&state.host, window.label())
}
/// Refreshes the model catalog; force bypasses freshness, not the cooldown.
#[tauri::command]
#[specta::specta]
pub fn agent_refresh_models(
window: Window,
state: State<'_, AppState>,
provider: String,
force: bool,
) -> IdeResult<AgentModelCatalog> {
agent::refresh_models(&state.host, window.label(), provider, force)
}
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
#[tauri::command]
-7
Переглянути файл
@@ -362,10 +362,3 @@ pub async fn db_apply_edits(
pub fn db_console_path(state: State<'_, AppState>, id: String) -> IdeResult<IpcPath> {
ide_host::ops::db::console_path(&state.host, &id)
}
/// The query consoles of data source `id`; `create` adds the next one first.
#[tauri::command]
#[specta::specta]
pub fn db_consoles(state: State<'_, AppState>, id: String, create: bool) -> IdeResult<Vec<IpcPath>> {
ide_host::ops::db::consoles(&state.host, &id, create)
}
-29
Переглянути файл
@@ -244,18 +244,6 @@ pub async fn debug_restart_frame(state: State<'_, AppState>, session: DebugSessi
ide_host::ops::debug::debug_restart_frame(&state.host, "", session, frame_id).await
}
/// The responses a debugged page gets in place of the server's, by a part of the URL; none
/// switches the replacing off.
#[tauri::command]
#[specta::specta]
pub async fn debug_js_response_overrides(
state: State<'_, AppState>,
session: DebugSessionId,
rules: Vec<ide_dap::JsResponseRule>,
) -> IdeResult<()> {
ide_host::ops::debug::debug_js_response_overrides(&state.host, "", session, rules).await
}
/// The body of a response a debugged page received.
#[tauri::command]
#[specta::specta]
@@ -279,23 +267,6 @@ pub async fn debug_js_custom_breakpoints(
ide_host::ops::debug::debug_js_custom_breakpoints(&state.host, "", session, ids, xhr).await
}
/// Starts recording a debugged page's CPU profile.
#[tauri::command]
#[specta::specta]
pub async fn debug_js_start_profile(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<()> {
ide_host::ops::debug::debug_js_start_profile(&state.host, "", session).await
}
/// Ends the recording: a directory holding the profile as `page.cpuprofile`.
#[tauri::command]
#[specta::specta]
pub async fn debug_js_stop_profile(
state: State<'_, AppState>,
session: DebugSessionId,
) -> IdeResult<ide_core::IpcPath> {
ide_host::ops::debug::debug_js_stop_profile(&state.host, "", session).await
}
#[tauri::command]
#[specta::specta]
pub fn debug_capabilities(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
-8
Переглянути файл
@@ -123,7 +123,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
app::log_frontend,
agent::agent_subscribe,
agent::agent_providers,
agent::agent_refresh_models,
agent::agent_set_command,
agent::agent_set_env,
agent::agent_custom_save,
@@ -249,7 +248,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::web_goto,
nav::nav_resolve_module,
nav::nav_index_definitions,
nav::nav_inheritance,
nav::fs_resolve_reference,
nav::path_completions,
nav::web_unresolved_references,
@@ -477,8 +475,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
sdk::sdk_python_interpreter,
sdk::sdk_python_effective,
sdk::sdk_php_effective,
sdk::sdk_container_python,
sdk::sdk_container_python_refresh,
sdk::sdk_php_xdebug,
sdk::sdk_php_language_level,
sdk::sdk_php_composer_install,
@@ -533,9 +529,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
debug::debug_capabilities,
debug::debug_js_custom_breakpoints,
debug::debug_js_response_body,
debug::debug_js_response_overrides,
debug::debug_js_start_profile,
debug::debug_js_stop_profile,
debug::debug_set_function_breakpoints,
debug::debug_set_data_breakpoints,
debug::debug_data_breakpoint_info,
@@ -753,6 +746,5 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
db::db_table_page,
db::db_apply_edits,
db::db_console_path,
db::db_consoles,
])
}
-8
Переглянути файл
@@ -115,14 +115,6 @@ pub async fn nav_index_definitions(
ide_host::ops::nav::nav_index_definitions(&state.host, window.label(), name, from).await
}
/// Every Python class in the project (attached dependency roots included) and its base names,
/// for native inheritance gutter markers.
#[tauri::command]
#[specta::specta]
pub async fn nav_inheritance(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ClassInheritance>> {
ide_host::ops::nav::nav_inheritance(&state.host, window.label()).await
}
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
#[tauri::command]
#[specta::specta]
-20
Переглянути файл
@@ -38,26 +38,6 @@ pub fn sdk_python_effective(
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label(), near)
}
/// The project interpreter when it is a Docker or Compose run target's, with its snapshot.
#[tauri::command]
#[specta::specta]
pub fn sdk_container_python(
window: Window,
state: State<'_, AppState>,
) -> IdeResult<Option<ide_host::ops::sdk::ContainerPython>> {
ide_host::ops::sdk::sdk_container_python(&state.host, window.label())
}
/// Copies the container's Python libraries again (after the image changed).
#[tauri::command]
#[specta::specta]
pub async fn sdk_container_python_refresh(
window: Window,
state: State<'_, AppState>,
) -> IdeResult<Option<ide_host::ops::sdk::ContainerPython>> {
ide_host::ops::sdk::sdk_container_python_refresh(&state.host, window.label()).await
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
#[tauri::command]
#[specta::specta]
+3 -122
Переглянути файл
@@ -136,8 +136,6 @@ export const commands = {
/** Streams the project's agent events to this window; returns every task. */
agentSubscribe: (onEvent: Channel<AgentEvent>) => typedError<AgentTask[], IdeError>(__TAURI_INVOKE("agent_subscribe", { onEvent })),
agentProviders: () => typedError<AgentProvider[], IdeError>(__TAURI_INVOKE("agent_providers")),
/** Refreshes the model catalog; force bypasses freshness, not the cooldown. */
agentRefreshModels: (provider: string, force: boolean) => typedError<AgentModelCatalog, IdeError>(__TAURI_INVOKE("agent_refresh_models", { provider, force })),
/**
* Overrides built-in agent `id`'s command with `command` (its agent file keeps the
* built-in's driver and arguments), or returns it to auto-detection with `None`.
@@ -477,11 +475,6 @@ export const commands = {
* plus its configured dependencies).
*/
navIndexDefinitions: (name: string, from: IpcPath) => typedError<SymbolMatch[], IdeError>(__TAURI_INVOKE("nav_index_definitions", { name, from })),
/**
* Every Python class in the project (attached dependency roots included) and its base names,
* for native inheritance gutter markers.
*/
navInheritance: () => typedError<ClassInheritance[], IdeError>(__TAURI_INVOKE("nav_inheritance")),
/** The existing file or directory a path written in `from` names (`href`, `url()`, a string literal). */
fsResolveReference: (from: IpcPath, value: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("fs_resolve_reference", { from, value })),
/** Files and folders that continue `prefix`, a path being typed in `from`. */
@@ -1038,40 +1031,6 @@ export const commands = {
sdkPythonEffective: (near: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective", { near })),
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
/** The project interpreter when it is a Docker or Compose run target's, with its snapshot. */
sdkContainerPython: () => typedError<{
/** The run target's id. */
target: string,
/** Its name; empty when no run target has the id. */
name: string,
/** The Python version of the snapshot in use; `None`: no snapshot yet. */
version: string | null,
/** The image the snapshot was copied from. */
image: string | null,
/** The copied library folders on this machine. */
folders: IpcPath[],
/** Packages of the snapshot (read-only). */
packages: PythonPackage[],
/** A snapshot is being taken. */
updating: boolean,
} | null, IdeError>(__TAURI_INVOKE("sdk_container_python")),
/** Copies the container's Python libraries again (after the image changed). */
sdkContainerPythonRefresh: () => typedError<{
/** The run target's id. */
target: string,
/** Its name; empty when no run target has the id. */
name: string,
/** The Python version of the snapshot in use; `None`: no snapshot yet. */
version: string | null,
/** The image the snapshot was copied from. */
image: string | null,
/** The copied library folders on this machine. */
folders: IpcPath[],
/** Packages of the snapshot (read-only). */
packages: PythonPackage[],
/** A snapshot is being taken. */
updating: boolean,
} | null, IdeError>(__TAURI_INVOKE("sdk_container_python_refresh")),
/**
* The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the
* project root).
@@ -1259,15 +1218,6 @@ export const commands = {
debugJsCustomBreakpoints: (session: DebugSessionId, ids: string[], xhr: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("debug_js_custom_breakpoints", { session, ids, xhr })),
/** The body of a response a debugged page received. */
debugJsResponseBody: (session: DebugSessionId, requestId: string) => typedError<JsResponseBody, IdeError>(__TAURI_INVOKE("debug_js_response_body", { session, requestId })),
/**
* The responses a debugged page gets in place of the server's, by a part of the URL; none
* switches the replacing off.
*/
debugJsResponseOverrides: (session: DebugSessionId, rules: JsResponseRule[]) => typedError<null, IdeError>(__TAURI_INVOKE("debug_js_response_overrides", { session, rules })),
/** Starts recording a debugged page's CPU profile. */
debugJsStartProfile: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_js_start_profile", { session })),
/** Ends the recording: a directory holding the profile as `page.cpuprofile`. */
debugJsStopProfile: (session: DebugSessionId) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("debug_js_stop_profile", { session })),
/** Method breakpoints (all files); states aligned with `breakpoints`. */
debugSetFunctionBreakpoints: (breakpoints: FunctionBreakpoint[]) => typedError<BreakpointState[], IdeError>(__TAURI_INVOKE("debug_set_function_breakpoints", { breakpoints })),
/** Field watchpoints; states aligned with `breakpoints`. */
@@ -1776,8 +1726,6 @@ export const commands = {
dbTablePage: (request: RequestId, id: string, schema: string, table: string, filter: string | null, sort: DbSortKey[], offset: number, limit: number) => typedError<DbTablePage, IdeError>(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })),
dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError<number | null, IdeError>(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })),
dbConsolePath: (id: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("db_console_path", { id })),
/** The query consoles of data source `id`; `create` adds the next one first. */
dbConsoles: (id: string, create: boolean) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("db_consoles", { id, create })),
};
/* Types */
@@ -2015,8 +1963,6 @@ export type AgentEvent =
{ type: "append"; task: string; subagent: string | null; index: number; from: number; text: string } |
/** An agent reported the usage limits of its account (shared by every task of `provider`). */
{ type: "limits"; provider: string; limits: AgentLimit[] } |
/** A model lookup started or completed, including unsuccessful lookups. */
{ type: "modelCatalog"; catalog: AgentModelCatalog } |
/** The installed or configured agent providers changed. */
{ type: "providers" };
@@ -2072,17 +2018,6 @@ export type AgentMcpServer_Serialize = {
agents?: string[],
};
/** Authoritative choices for new tasks, independent of any running session's settings. */
export type AgentModelCatalog = {
provider: string,
config: AgentConfigOption[],
thoughtLevels: { [key in string]: AgentConfigOption[] },
refreshing: boolean,
refreshedAtMs: number | null,
retryAtMs: number | null,
error: string | null,
};
export type AgentPermissionOption = {
id: string,
name: string,
@@ -2801,7 +2736,7 @@ export type BuildResult = {
messages: BuildMessage[],
};
export type BuildSystem = "maven" | "gradle" | "cargo" | "go" | "typescript" | "cmake" | "dotnet";
export type BuildSystem = "maven" | "gradle" | "cargo" | "go" | "typescript" | "cmake";
export type BuildTool = "maven" | "gradle";
@@ -2954,25 +2889,6 @@ export type ChoiceOption = {
label: string,
};
/**
* A Python class definition and the base names written in its source. The UI builds a
* name → locations index from every class in the project (attached dependency roots
* included) to compute ↑ overrides / ↓ overridden gutter markers without a language
* server's type-hierarchy provider.
*/
export type ClassInheritance = {
/** Start of the class definition's selection range (0-based line, UTF-16 column). */
path: IpcPath,
line: number,
col: number,
name: string,
/**
* Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
* qualified name keeps only its last segment).
*/
bases: string[],
};
export type ClassifiedReference = {
/** Optional for older Java extensions; missing means unclassified. */
access?: ReferenceAccess | null,
@@ -3423,24 +3339,6 @@ export type ConsoleSettings = {
export type ContainerOp = "start" | "stop" | "restart" | "pause" | "unpause" | "kill";
/** The project interpreter when it is a run target's. */
export type ContainerPython = {
/** The run target's id. */
target: string,
/** Its name; empty when no run target has the id. */
name: string,
/** The Python version of the snapshot in use; `None`: no snapshot yet. */
version: string | null,
/** The image the snapshot was copied from. */
image: string | null,
/** The copied library folders on this machine. */
folders: IpcPath[],
/** Packages of the snapshot (read-only). */
packages: PythonPackage[],
/** A snapshot is being taken. */
updating: boolean,
};
/** ContentAction data exchanged with the IDE. */
export type ContentAction = {
id: string,
@@ -4120,8 +4018,6 @@ export type DebuggerSettings = {
showOnBreakpoint?: boolean,
focusAppOnBreakpoint?: boolean,
hideOnTermination?: boolean,
/** A breakpoint or exception hit brings the Debug tool window's Threads & Variables tab to the front. */
variablesOnBreakpoint?: boolean,
breakpointsAnyFile?: boolean,
/** While suspended, hovering the start of a line shows a Run to Cursor button. */
runToCursorInlay?: boolean,
@@ -6055,9 +5951,7 @@ cancellable: boolean } |
/** Project indexing state changed (dumb mode on/off). */
{ type: "indexState"; indexing: boolean; files: number; symbols: number } |
/** Git status / branch information should be refreshed. */
{ type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string;
/** What the user sees as the server when another program serves in its place. */
label?: string | null; status: ServerStatus; message: string | null } |
{ type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } |
/** A running server lacks a capability; the UI offers the managed tool as a replacement. */
{ type: "languageServerWarning"; name: string; message: string; installTool: string } |
/**
@@ -6578,14 +6472,6 @@ export type JsResponseBody = {
truncated: boolean,
};
/** A response given in place of the server's to every request whose URL contains `url_contains`. */
export type JsResponseRule = {
urlContains: string,
status: number,
contentType: string,
body: string,
};
/** The backend that reads a debugged JVM's objects (instances, fields, referrers). */
export type JvmBackendState = { kind: "ready" } |
/** Turned off for the rest of the session: the debug adapter lacks what it needs. */
@@ -8788,10 +8674,7 @@ export type PythonPackage = {
};
export type PythonSettings = {
/**
* Project interpreter executable; empty = auto-detect (project venv, else `python3`);
* `target:<run-target-id>` = the Python of a Docker or Compose run target.
*/
/** Project interpreter executable; empty = auto-detect (project venv, else `python3`). */
interpreter?: string,
/** Activate the interpreter's virtualenv in new terminal sessions. */
activateVirtualenv?: boolean,
@@ -9532,8 +9415,6 @@ export type ServerInfo = {
status: ServerStatus,
/** OS process of the running instance (for memory attribution). */
pid: number | null,
/** What the user sees as the server when another program serves in its place. */
label?: string | null,
};
export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped" | "missing" |
+4 -23
Переглянути файл
@@ -270,29 +270,13 @@
}
.tabsVertical > .tabs,
.tabsVertical > .tabRows {
/* Fits the labels up to the draggable limit (SideTabsResizer); the editor keeps at least 60%. */
width: max-content;
max-width: min(var(--side-tabs-limit, 260px), 40%);
/* A narrow editor group keeps most of its width for the editor; labels truncate. */
min-width: min(140px, 40%);
max-width: min(260px, 40%);
border-bottom: none;
border-right: 1px solid var(--border-editor-tabs, var(--border));
}
/* A zero-width handle on the column's inner border, with a wider hit area. */
.sideTabsResizer {
position: relative;
z-index: 1;
flex: none;
width: 0;
outline: none;
}
.sideTabsResizer::after {
content: "";
position: absolute;
inset: 0 -3px;
cursor: ew-resize;
}
.sideTabsResizer:focus-visible::after {
background: color-mix(in srgb, var(--focus-ring) 55%, transparent);
}
.tabsVertical .tabRows > .tabs {
min-height: 0;
border-bottom: none;
@@ -301,12 +285,9 @@
flex: 1 1 auto;
border-top: 1px solid var(--border-editor-tabs, var(--border));
}
.tabsRight > .sideTabsResizer {
order: 1;
}
.tabsRight > .tabs,
.tabsRight > .tabRows {
order: 2;
order: 1;
border-right: none;
border-left: 1px solid var(--border-editor-tabs, var(--border));
}
+1 -7
Переглянути файл
@@ -64,7 +64,6 @@ import { TextEditorHost } from "./TextEditorHost";
import { FindBar } from "./FindBar";
import { EditorBanners, VirtualBanner } from "./Banner";
import { CodeBreadcrumbs } from "./StatusItems";
import { SideTabsResizer, useSideTabsStyle } from "./SideTabsResizer";
import styles from "./EditorArea.module.css";
import { tx } from "@/platform/i18n";
@@ -220,9 +219,7 @@ function PaneView({ paneId }: { paneId: string }) {
: undefined,
italic: t.preview,
modified: dirty[i],
// IDEA: hiding the close button is visual only. Keep close gestures
// (middle click, Shift+click, Delete) alive when its position is None.
showCloseButton,
closable: showCloseButton,
color: colors[i] || undefined,
tint: tints[i] || undefined,
tooltip: virtualTooltip(t.input) ?? (showFullPathOnHover ? (t.input.path ?? name) : undefined),
@@ -255,7 +252,6 @@ function PaneView({ paneId }: { paneId: string }) {
const pinnedCount = tabs.filter((t) => t.pinned).length;
// Settings › Editor Tabs › Tab placement Left / Right: a vertical, scrolling column.
const vertical = tabPrefs.placement === "left" || tabPrefs.placement === "right";
const sideTabsStyle = useSideTabsStyle();
const pinnedRow = tabPrefs.pinnedSeparateRow && pinnedCount > 0 && pinnedCount < tabs.length;
const dnd = useMemo(() => {
// A tab released outside the window detaches into a forced new window.
@@ -370,7 +366,6 @@ function PaneView({ paneId }: { paneId: string }) {
vertical && styles.tabsVertical,
tabPrefs.placement === "right" && styles.tabsRight,
)}
style={vertical ? sideTabsStyle : undefined}
data-pane-id={paneId}
data-focus-scope=""
aria-label={tx("Editor")}
@@ -386,7 +381,6 @@ function PaneView({ paneId }: { paneId: string }) {
) : (
strip(items, dnd.all, actions)
)}
{vertical && !hidden && <SideTabsResizer side={tabPrefs.placement === "left" ? "left" : "right"} />}
<div
className={styles.content}
role="tabpanel"
-106
Переглянути файл
@@ -1,106 +0,0 @@
/**
* Width limit of editor tabs placed left / right, as in IDEA: the column fits its
* labels up to this limit, and dragging its inner edge sets the limit for every
* editor group. Remembered across sessions.
*/
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { create } from "zustand";
import { lockDragCursor } from "@/ui";
import { tx } from "@/platform/i18n";
import styles from "./EditorArea.module.css";
const KEY = "openidea.editor.sideTabsWidth";
const MIN_WIDTH = 80;
const MAX_WIDTH = 800;
const KEY_STEP = 16;
/** CSS custom property read by `.tabsVertical` in EditorArea.module.css. */
const LIMIT_VAR = "--side-tabs-limit";
function load(): number | null {
try {
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? "null");
return typeof v === "number" && Number.isFinite(v) ? clamp(v) : null;
} catch {
return null;
}
}
function clamp(px: number) {
return Math.round(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, px)));
}
/** `null`: the default limit from the stylesheet. */
const useSideTabsWidth = create<{ width: number | null }>(() => ({ width: load() }));
function setSideTabsWidth(px: number) {
const width = clamp(px);
useSideTabsWidth.setState({ width });
try {
localStorage.setItem(KEY, JSON.stringify(width));
} catch {
// Storage unavailable: the width lasts for this session.
}
}
/** Style for the editor pane that holds a vertical tab column. */
export function useSideTabsStyle(): CSSProperties | undefined {
const width = useSideTabsWidth((s) => s.width);
return width == null ? undefined : ({ [LIMIT_VAR]: `${width}px` } as CSSProperties);
}
/** The drag handle on the editor side of the tab column; render it right after the column. */
export function SideTabsResizer({ side }: { side: "left" | "right" }) {
const sign = side === "left" ? 1 : -1;
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
const handle = e.currentTarget;
const pane = handle.parentElement;
const start = handle.previousElementSibling?.getBoundingClientRect().width;
if (e.button !== 0 || !pane || start == null) return;
// No focus ring or text selection from a mouse drag.
e.preventDefault();
handle.setPointerCapture(e.pointerId);
const origin = e.clientX;
const previous = pane.style.getPropertyValue(LIMIT_VAR);
const releaseCursor = lockDragCursor("ew-resize");
let next: number | null = null;
// Only this pane follows the pointer; every group takes the new width on release.
const move = (ev: globalThis.PointerEvent) => {
if (!(ev.buttons & 1)) return end();
next = clamp(start + sign * (ev.clientX - origin));
pane.style.setProperty(LIMIT_VAR, `${next}px`);
};
const end = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", end);
handle.removeEventListener("pointercancel", end);
releaseCursor();
if (next != null) setSideTabsWidth(next);
else if (previous) pane.style.setProperty(LIMIT_VAR, previous);
else pane.style.removeProperty(LIMIT_VAR);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", end);
handle.addEventListener("pointercancel", end);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const width = e.currentTarget.previousElementSibling?.getBoundingClientRect().width;
if (!dir || width == null) return;
e.preventDefault();
setSideTabsWidth(width + sign * dir * KEY_STEP);
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={tx("Resize editor tabs")}
tabIndex={0}
className={styles.sideTabsResizer}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
/>
);
}
-11
Переглянути файл
@@ -80,15 +80,4 @@ describe("pinned tabs in a separate row", () => {
fireEvent.mouseDown(screen.getByRole("tab", { name: /f2/ }), { button: 1 });
expect(closeTab).toHaveBeenCalledWith("p1", "t2");
});
// Regression: Settings › Editor Tabs › "Close button position: None" hides the ×
// but must not disable middle-click. EditorArea used to pass closable:
// showCloseButton, so hiding the button made the tab unclosable.
it("middle click still closes when the close button is hidden (position None)", () => {
seed([false, false, false], { placement: "top", showCloseButton: false });
render(<EditorAreaView />);
expect(screen.queryByRole("button", { name: "Close Tab" })).toBeNull();
fireEvent.mouseDown(screen.getByRole("tab", { name: /f2/ }), { button: 1 });
expect(closeTab).toHaveBeenCalledWith("p1", "t2");
});
});
+20 -54
Переглянути файл
@@ -71,7 +71,7 @@ import {
import { showHint, showPendingHint } from "./hint";
import { outsideProjectReason } from "./outsideProject";
import { caretAnchor, openPopup, updatePopup, type ListPopupItem } from "./popupStore";
import { readManyFileLines, targetLabel, withDocument } from "./files";
import { readManyFileLines, targetLabel } from "./files";
import {
documentLinks,
followReference,
@@ -85,7 +85,6 @@ import { featureBackoff } from "./availability";
import { requestHover, showPhpConstructDocumentation } from "./hover";
import { declarationSummary, renderCode } from "./markdown";
import { enclosingType } from "@/editor/navigation/lineMarkers";
import { projectPythonHierarchy, pythonImplementationTargets } from "@/editor/navigation/pythonHierarchy";
import { editorPrefs } from "@/platform/editorSettings";
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
import { isAddCaretGesture } from "@/editor/core/cm/multiCaret";
@@ -371,30 +370,6 @@ async function onDeclarationName(view: EditorView, offset: number): Promise<bool
return isDeclarationName(at);
}
/**
* Go to Implementation targets for Python from the IDE's inheritance graph: its servers
* declare no type hierarchy and analyse one root, so the graph also knows the attached
* dependency project's implementations.
*/
async function pythonImplementations(doc: EditorDocContext, pos: Pos): Promise<DeclarationTarget[]> {
if (doc.language !== "python") return [];
const outline = (path: string) =>
path === doc.path
? lspRequest(() => commands.docOutline(doc.doc))
: withDocument(path, (d) => lspRequest(() => commands.docOutline(d)));
const hierarchy = await projectPythonHierarchy().catch(() => null);
if (!hierarchy) return [];
return pythonImplementationTargets(hierarchy, doc.path, pos, outline).catch(() => []);
}
/** `more` after `first`, without a target whose declaration line is already there. */
function mergeTargets(first: readonly DeclarationTarget[], more: readonly DeclarationTarget[]): DeclarationTarget[] {
return dedupeLocations([
...first,
...more.filter((m) => !first.some((f) => f.path === m.path && f.range.start.line === m.range.start.line)),
]);
}
async function gotoResolved(
view: EditorView,
kind: GotoKind,
@@ -444,9 +419,6 @@ async function gotoResolved(
return;
}
}
// Python: its servers have no type hierarchy and see one root, so the IDE's inheritance
// graph answers implementations too (merged with the server's, not replacing them).
const graphTargets = kind === "implementation" ? pythonImplementations(doc, pos) : null;
let serverDown = false;
let streamed: ReturnType<typeof streamedImplementations> | null = null;
try {
@@ -457,38 +429,32 @@ async function gotoResolved(
} catch (e) {
// The chooser shows the first ones: closing it cancelled the search, or the rest failed.
if (streamed && (await streamed.opened())) {
await streamed.complete(graphTargets ? await graphTargets : []);
await streamed.complete([]);
return;
}
if (superseded()) return;
// Python: the graph answers implementations the server cannot (no type hierarchy).
const graph = graphTargets ? await graphTargets : [];
if (superseded()) return;
if (graph.length) locs = graph;
else if (isCancelled(e) || !declaration) {
if (isCancelled(e) || !declaration) {
reportLspError(view, e, true);
return;
} else {
// Web references and links need no server.
if (
(await gotoWebReference(view, doc, pos, offset, superseded)) ||
(await followReferenceAt(view, doc, offset, superseded))
)
return;
serverDown = isUnavailable(e);
// Dumb mode: the server is not ready / failing — answer from the syntax tree.
// An import path is not a name to look up: only its file matters.
const fb = moduleName ? null : await gotoFallback(view, doc, pos);
if (superseded()) return;
if (!fb) {
reportLspError(view, e, true);
return;
}
locs = fb;
fromSyntax = true;
}
// Web references and links need no server.
if (
(await gotoWebReference(view, doc, pos, offset, superseded)) ||
(await followReferenceAt(view, doc, offset, superseded))
)
return;
serverDown = isUnavailable(e);
// Dumb mode: the server is not ready / failing — answer from the syntax tree.
// An import path is not a name to look up: only its file matters.
const fb = moduleName ? null : await gotoFallback(view, doc, pos);
if (superseded()) return;
if (!fb) {
reportLspError(view, e, true);
return;
}
locs = fb;
fromSyntax = true;
}
if (graphTargets) locs = mergeTargets(locs, await graphTargets);
if (streamed && (await streamed.opened())) {
await streamed.complete(locs);
return;
-128
Переглянути файл
@@ -1,128 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { commands, Location, OutlineItem } from "@/platform/ipc";
// Python has no type hierarchy server: the IDE's inheritance graph answers implementations.
const graph = vi.hoisted(() => [
{ path: "/p/order.py", line: 0, col: 6, name: "Order", bases: [] },
{ path: "/p/online.py", line: 0, col: 6, name: "OnlineOrder", bases: ["Order"] },
]);
// The project index is the production entry: build one from the graph above so the
// tests never reach the IPC the real fetch needs.
vi.mock("@/editor/navigation/inheritanceGraph", async (orig) => {
const real = await orig<typeof import("@/editor/navigation/inheritanceGraph")>();
const index = real.inheritanceIndex(graph, { readFiles: async () => new Map() });
const subclasses = await real.subclassMap(index);
return {
...real,
pythonInheritanceGraph: async () => graph,
projectPythonIndex: async () => ({ classes: graph, index, subclasses: async () => subclasses }),
};
});
const { lspGotoImplementation, docOutline, fsReadText } = vi.hoisted(() => ({
lspGotoImplementation: vi.fn<typeof commands.lspGotoImplementation>(),
docOutline: vi.fn<typeof commands.docOutline>(),
fsReadText: vi.fn<typeof commands.fsReadText>(async () => ({
status: "error",
error: { kind: "notFound", message: "not found" },
})),
}));
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: { ...real.commands, lspGotoImplementation, docOutline, fsReadText },
};
});
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
},
}));
const { gotoTargets } = await import("./navigation");
const { lspDoc } = await import("./lsp");
const { useLspPopup, closePopup } = await import("./popupStore");
const { provideEditorService } = await import("@/workbench/contributions");
const openFile = vi.fn(async () => {});
provideEditorService({ openFile } as never);
const range = (line: number, col: number) => ({ start: { line, col }, end: { line, col } });
const at = (path: string, line: number): Location => ({
path,
range: { start: { line, col: 0 }, end: { line, col: 1 } },
});
// class Order:
// def save(self):
const OUTLINE: OutlineItem[] = [
{
name: "Order",
detail: null,
kind: "class",
range: { start: { line: 0, col: 0 }, end: { line: 2, col: 8 } },
selection_range: range(0, 6),
children: [
{
name: "save",
detail: null,
kind: "method",
range: { start: { line: 1, col: 4 }, end: { line: 2, col: 8 } },
selection_range: range(1, 8),
children: [],
},
],
},
];
let view: EditorView;
beforeEach(() => {
vi.clearAllMocks();
docOutline.mockResolvedValue({ status: "ok", data: OUTLINE });
lspGotoImplementation.mockResolvedValue({ status: "ok", data: [] });
view = new EditorView({
state: EditorState.create({
doc: "class Order:\n def save(self):\n pass\n",
extensions: [lspDoc.of({ doc: 1, path: "/p/order.py", language: "python", uiMode: "Python", readOnly: false })],
}),
parent: document.body,
});
view.dispatch({ selection: { anchor: 6 } });
});
afterEach(() => {
closePopup();
view.destroy();
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe("Go to Implementation on Python", () => {
it("answers from the IDE's inheritance graph when the server finds none", async () => {
await gotoTargets(view, "implementation");
expect(openFile).toHaveBeenCalledWith("/p/online.py", { selection: { line: 0, col: 6 }, focus: true });
expect(lspGotoImplementation).toHaveBeenCalledOnce();
});
it("merges the graph's implementations with the server's answer", async () => {
lspGotoImplementation.mockResolvedValue({ status: "ok", data: [at("/p/other.py", 3)] });
await gotoTargets(view, "implementation");
const popup = useLspPopup.getState().popup;
expect(popup?.kind).toBe("list");
expect(popup?.kind === "list" ? popup.items : []).toHaveLength(2);
expect(popup?.kind === "list" ? popup.title : null).toBe("Choose Implementation of Order (2 found)");
expect(openFile).not.toHaveBeenCalled();
});
it("falls back to the graph when the server has no type hierarchy", async () => {
lspGotoImplementation.mockResolvedValue({
status: "error",
error: { kind: "unavailable", message: "no type hierarchy" },
});
await gotoTargets(view, "implementation");
expect(openFile).toHaveBeenCalledWith("/p/online.py", { selection: { line: 0, col: 6 }, focus: true });
});
});
+6 -186
Переглянути файл
@@ -27,14 +27,12 @@ import {
isCancelled,
nextRequestId,
onIdeEvent,
type ClassInheritance,
type HierarchyItem,
type Location,
type OutlineItem,
type Pos,
type RequestId,
} from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { editorPrefs, type EditorExtras } from "@/platform/editorSettings";
import { useProject } from "@/platform/project";
import { gutterIconMarkup } from "@/ui";
@@ -51,15 +49,6 @@ import {
type MarkerGroup,
} from "./lineMarkers";
import { overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
import {
classLocation,
declarationKey,
forgetInheritanceGraph,
inheritanceIndex,
methodInOutline,
pythonInheritanceGraph,
type InheritanceIndex,
} from "./inheritanceGraph";
import styles from "./LineMarkers.module.css";
/** Lines above / below the viewport that are asked with it. */
@@ -96,25 +85,16 @@ export function affectsMarkers(paths: readonly string[]): boolean {
return paths.some((p) => !OUTPUT_PATH.test(p));
}
/**
* The Python inheritance graph of the open project, shared by every document and kept
* across edits: it is a whole-project index answer, not a per-document one, so scanning
* it again for each edit would walk every Python file again. Dropped when other files
* change or a server settles (the index may have moved on).
*/
/** Subscribes once per window and stays subscribed, so the settle echo guard survives remounts. */
function watchChanges() {
if (watching) return;
watching = true;
onIdeEvent("fsChanged", (e) => {
if (!affectsMarkers([...e.created, ...e.modified, ...e.removed])) return;
changes++;
forgetInheritanceGraph();
if (affectsMarkers([...e.created, ...e.modified, ...e.removed])) changes++;
});
// Answers (outlines too) given while a server was starting or indexing may be incomplete.
onServerSettled(() => {
docMarkers.clear();
forgetInheritanceGraph();
for (const plugin of live) plugin.refreshSoon();
});
}
@@ -151,18 +131,6 @@ export interface MarkerDeps {
implementations: (positions: Pos[]) => Promise<Location[][]>;
supers: SuperDeps;
readFiles: (paths: readonly string[]) => Promise<Map<string, string[] | null>>;
/**
* Every Python class of the project and its base names (the IDE index, attached
* dependency roots included). Some servers (basedpyright, pyrefly) answer no
* `typeHierarchyProvider`, so ↑/↓ are computed from this graph instead.
* Absent for languages whose servers do provide one (Java, Go, ...).
*/
inheritance?: () => Promise<ClassInheritance[]>;
/**
* The file a Python `import` path names (`from a.b import X`), `.x` resolved
* relative to `from`. Disambiguates a base name two files both declare.
*/
moduleFile?: (module: string, from: string) => Promise<string | null>;
}
interface CacheEntry {
@@ -174,8 +142,6 @@ interface CacheEntry {
export interface MarkerCache {
entries: Map<string, CacheEntry>;
unsupported: Set<MarkerGroup>;
/** The Python inheritance graph for this document version, fetched once per cache. */
inheritance?: Promise<ClassInheritance[] | null>;
}
export const emptyCache = (): MarkerCache => ({ entries: new Map(), unsupported: new Set() });
@@ -198,16 +164,11 @@ export async function fillMarkers(
if (!e) cache.entries.set(key, (e = {}));
return e;
};
// A Python inheritance graph answers both groups even when the language server
// has no type hierarchy: it must keep the candidates alive through the LSP
// "unsupported" bookkeeping, or the fallback would never get to run.
const graph = deps.inheritance ? await (cache.inheritance ??= deps.inheritance().catch(() => null)) : null;
const fallback = (graph?.length ?? 0) > 0;
const down =
groups.has("inheritors") && (fallback || !cache.unsupported.has("inheritors"))
groups.has("inheritors") && !cache.unsupported.has("inheritors")
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
: [];
const askSupers = groups.has("supers") && (fallback || !cache.unsupported.has("supers"));
const askSupers = groups.has("supers") && !cache.unsupported.has("supers");
const up = askSupers
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
: [];
@@ -225,11 +186,8 @@ export async function fillMarkers(
asked = await deps.implementations(ask.map((c) => ({ line: c.line, col: c.col })));
} catch (e) {
if (isCancelled(e)) throw e;
// With a fallback the candidates stay: the graph may still answer them.
if (!fallback) {
if (isUnavailable(e)) cache.unsupported.add("inheritors");
for (let i = down.length - 1; i >= 0; i--) if (!known.has(down[i]!)) down.splice(i, 1);
}
if (isUnavailable(e)) cache.unsupported.add("inheritors");
for (let i = down.length - 1; i >= 0; i--) if (!known.has(down[i]!)) down.splice(i, 1);
}
}
implementations = down.map((c) => withoutSelf(path, c, known.get(c) ?? asked[ask.indexOf(c)] ?? []));
@@ -250,83 +208,8 @@ export async function fillMarkers(
}),
);
}
// Python without a type-hierarchy provider: the IDE's inheritance graph holds every
// `class X(A)` of the project, so ↑/↓ are computed from it instead.
const resolver: InheritanceIndex | null = graph ? inheritanceIndex(graph, deps) : null;
const byName = resolver?.byName ?? new Map<string, ClassInheritance[]>();
// The inheritance graph's direct subclasses, keyed by the *declaration* each base
// resolves to (path + position), so classes sharing a bare name never mix up.
const subclassesOf = new Map<string, ClassInheritance[]>();
for (const cls of graph ?? []) {
for (const base of new Set(cls.bases)) {
// Only this file's classes are looked up below: read another file's imports only
// when its base may name one of them, not for every shared or library base name.
const exact = resolver ? resolver.exact(base, cls.path) : [];
const named = exact.length || !byName.get(base)?.some((c) => c.path === path) ? exact : null;
for (const decl of named ?? (await resolver!.resolve(base, cls.path))) {
const key = declarationKey(decl);
subclassesOf.set(key, [...(subclassesOf.get(key) ?? []), cls]);
}
}
}
const classOf = (c: MarkerCandidate): ClassInheritance | null => {
// A class candidate is its own declaration; a method's is the `owner` it records.
const at = c.method ? c.owner : { line: c.line, col: c.col };
if (!at) return null;
const cls = candidates.find((k) => !k.method && k.line === at.line && k.col === at.col) ?? (c.method ? null : c);
if (!cls) return null;
return byName.get(cls.name)?.find((x) => x.path === path && x.line === cls.line && x.col === cls.col) ?? null;
};
/** The declarations a subclass overrides: the subclass itself, or its same-named method. */
const subclassDeclarations = async (c: MarkerCandidate, owner: ClassInheritance): Promise<Location[]> => {
const subs = subclassesOf.get(declarationKey(owner)) ?? [];
if (!c.method) return subs.map(classLocation);
const bare = c.symbol.replace(/\s*[(<].*$/, "");
const targets: Location[] = [];
for (const sub of subs) {
const outline = await deps.supers.outline(sub.path).catch(() => [] as OutlineItem[]);
const m = methodInOutline(outline, sub, bare);
if (m) targets.push({ path: sub.path, range: m.selection_range });
}
return targets;
};
if (byName.size) {
for (let i = 0; i < down.length; i++) {
const c = down[i]!;
const owner = classOf(c);
if (!owner) continue;
// Merge, never replace: a server that answers sees one root, so its list can miss
// the subclasses living in an attached project while still being non-empty.
implementations[i] = withoutSelf(
path,
c,
mergeLocations(implementations[i] ?? [], await subclassDeclarations(c, owner)),
);
}
}
const supers: SuperMethod[][] = [];
for (const c of up) supers.push(await superMethodTargets(path, c.owner!, c.symbol, deps.supers));
if (byName.size) {
for (let i = 0; i < up.length; i++) {
const c = up[i]!;
const self = classOf(c);
if (!self) continue;
const found = resolver ? await superMethodsFrom(resolver, self.name, c.symbol, deps) : [];
if (!found.length) continue;
const known = supers[i]!;
// Same merge rule as ↓: add the graph's super methods to what the server answered.
const extra = found.map((f) => f.location).filter((l) => !known.some((s) => sameDeclarationLine(s.location, l)));
const owners = [...new Set(found.map((f) => f.owner))].sort((a, b) => a.localeCompare(b));
entry(c).supers = {
kind: "overriding",
line: c.line,
name: c.name,
targets: [...known.map((s) => s.location), ...extra],
tooltip: tx`Overrides method in ${owners.join(", ")}`,
chooserTitle: tx`Choose Super Method of ${c.name} (${known.length + extra.length} found)`,
};
}
}
// Go: an implementation request on a type or method answers the interfaces it satisfies.
let satisfied: Location[][] = [];
if (implicit.length) {
@@ -345,67 +228,10 @@ export async function fillMarkers(
...satisfied.flat().map((l) => l.path),
]);
down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[i] ?? [], files)));
// A candidate the graph answered already has its marker: don't null it out here.
up.forEach((c, i) => {
if (supers[i]!.length) entry(c).supers = supersMarker(c, supers[i]!, files);
});
up.forEach((c, i) => (entry(c).supers = supersMarker(c, supers[i]!, files)));
implicit.forEach((c, i) => (entry(c).supers = implementsMarker(c, withoutSelf(path, c, satisfied[i] ?? []), files)));
}
// One entry per declaration line: the server and the graph may point at the same
// declaration with different columns (the name vs the whole statement).
const sameDeclarationLine = (a: Location, b: Location) =>
a.path === b.path && a.range.start.line === b.range.start.line;
/** `first`, then the locations of `more` not already in it. */
function mergeLocations(first: readonly Location[], more: readonly Location[]): Location[] {
return [...first, ...more.filter((l) => !first.some((f) => sameDeclarationLine(f, l)))];
}
/** Identifies one class declaration in the inheritance graph. */
/**
* Super methods of `method` declared by a class in `byName`, walking the inheritance
* graph's bases up to a few levels. Empty when no base declares the method.
*/
async function superMethodsFrom(
resolver: InheritanceIndex,
start: string,
method: string,
deps: MarkerDeps,
depth = 3,
): Promise<{ location: Location; owner: string }[]> {
const bare = method.replace(/\s*[(<].*$/, "");
const seen = new Set<string>([start]);
let level = [start];
for (let d = 0; d < depth && level.length; d++) {
const next: string[] = [];
const found: { location: Location; owner: string }[] = [];
for (const name of level) {
for (const cls of resolver.byName.get(name) ?? []) {
for (const base of cls.bases) {
if (!seen.add(base)) continue;
// Same rule as the graph: a base the imports name, else a unique one.
const declarations = await resolver.resolve(base, cls.path);
let answered = false;
for (const baseCls of declarations) {
const outline = await deps.supers.outline(baseCls.path).catch(() => [] as OutlineItem[]);
const m = methodInOutline(outline, baseCls, bare);
if (m) {
found.push({ location: { path: baseCls.path, range: m.selection_range }, owner: base });
answered = true;
}
}
// Not found in a base that declares nothing: keep walking its own bases.
if (!answered) next.push(base);
}
}
}
if (found.length) return found;
level = next;
}
return [];
}
/**
* Overriders of a type's methods from one walk of its subtypes and their outlines, instead of one
* implementation request per method: jdtls builds the type hierarchy again for every implementation
@@ -749,12 +575,6 @@ function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: Reques
},
},
readFiles: readManyFileLines,
// Python servers answer no type hierarchy (basedpyright, pyrefly): the IDE's
// own index knows every `class X(Base)` of the project, attached roots included.
inheritance: ctx.language === "python" ? pythonInheritanceGraph : undefined,
// A base name several modules declare is resolved through the file's imports.
moduleFile:
ctx.language === "python" ? (name, from) => lspRequest(() => commands.navResolveModule(name, from)) : undefined,
};
}
+6 -17
Переглянути файл
@@ -19,8 +19,7 @@ import { isVirtualUri } from "@/platform/virtualDocs";
import { editorService } from "@/workbench/contributions";
import { fileIconName } from "@/ui";
import { gotoTestOrSubject, type TestNavDeps } from "./gotoTest";
import { projectPythonHierarchy, pythonSuperDeps } from "./pythonHierarchy";
import { baseName, memberAt, methodStop, superMethods, type SuperDeps } from "./structureNav";
import { baseName, memberAt, methodStop, superMethods } from "./structureNav";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
@@ -72,27 +71,17 @@ async function superFallback(ctx: EditorDocContext, pos: Pos): Promise<Location[
return dedupeLocations(locs).filter((l) => !(l.path === ctx.path && l.range.start.line === pos.line));
}
/** Super Method deps: Python answers them from the IDE's inheritance graph (its servers have no type hierarchy). */
async function superDeps(ctx: EditorDocContext): Promise<SuperDeps> {
const outline = (path: string) => withDoc(path, outlineOf);
if (ctx.language === "python") {
const hierarchy = await projectPythonHierarchy().catch(() => null);
if (hierarchy) return pythonSuperDeps(hierarchy, outline);
}
return {
supertypes: (path: string, pos: Pos) =>
withDoc(path, (doc) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"))),
outline,
};
}
async function superMethod(view: EditorView) {
const ctx = docOf(view);
if (!ctx) return;
const at = await caretOutline(view, ctx);
if (!at) return;
const { method, type } = memberAt(at.outline, at.caret);
const deps = await superDeps(ctx);
const deps = {
supertypes: (path: string, pos: Pos) =>
withDoc(path, (doc) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"))),
outline: (path: string) => withDoc(path, outlineOf),
};
let locs: Location[] = [];
if (method && type) locs = await superMethods(ctx.path, type.selection_range.start, method.name, deps);
else if (type) {
-293
Переглянути файл
@@ -1,293 +0,0 @@
/**
* The Python inheritance graph of the project, shared by every feature that needs it
* (gutter markers, type hierarchy, structure view, inspections).
*
* A language server's `typeHierarchy` is not used: basedpyright and pyrefly declare no
* `typeHierarchyProvider`, and a server analyses a single root, so an attached
* dependency project's classes would be invisible anyway. The IDE index knows every
* `class X(Base)` of every content root, which is the whole answer this needs.
*/
import { commands, type ClassInheritance, type Location, type OutlineItem } from "@/platform/ipc";
import { lspRequest } from "@/editor/lsp/lsp";
import { useProject } from "@/platform/project";
import { readManyFileLines } from "@/editor/lsp/files";
import { baseName } from "./structureNav";
/**
* The graph of the open project, shared across documents and kept across edits: it is a
* whole-project index answer, not a per-document one. Dropped when other files change or
* a server settles (the index may have moved on).
*/
let cache: { root: string; promise: Promise<ClassInheritance[]> } | null = null;
/** Forgets the cached graph and everything derived from it (files changed, a server settled, or tests). */
export function forgetInheritanceGraph() {
cache = null;
indexCache = null;
}
/** The project's classes and their base names, fetched once per project and invalidation. */
export function pythonInheritanceGraph(): Promise<ClassInheritance[]> {
const root = useProject.getState().project?.root ?? "";
if (!cache || cache.root !== root) {
cache = { root, promise: lspRequest(() => commands.navInheritance()) };
// A failed request must not stick: the next caller retries it.
cache.promise.catch(() => {
if (cache?.root === root) cache = null;
});
}
return cache.promise;
}
/** Where a name written in a file comes from: the module of a `from` import and the name it is declared under there. */
export interface ImportBinding {
module: string;
name: string;
}
/**
* The local name -> declaration bindings of a file's `from` imports:
* `from a.b import X as Y` binds `Y` to (`a.b`, `X`). Plain `import a.b` statements are
* skipped: a base written through them keeps its qualifier, which the graph does not store.
*/
export function importBindings(lines: readonly string[]): Map<string, ImportBinding> {
const out = new Map<string, ImportBinding>();
for (let i = 0; i < lines.length; i++) {
const line = (lines[i] ?? "").replace(/#.*$/, "");
const from = /^\s*from\s+(\.*[\w.]*)\s+import\s+(.*)$/.exec(line);
if (!from) continue;
let body = from[2]!.trim();
if (body.startsWith("(")) {
// The names may span lines inside the parentheses.
let text = body.slice(1);
while (!text.includes(")") && i + 1 < lines.length) text += ` ${(lines[++i] ?? "").replace(/#.*$/, "")}`;
body = text.slice(0, text.indexOf(")") < 0 ? undefined : text.indexOf(")"));
}
for (const part of body.split(",")) {
const [name, alias] = part.trim().split(/\s+as\s+/);
const declared = (name ?? "").trim();
if (!declared || declared === "*" || !/^\w+$/.test(declared)) continue;
const local = (alias ?? declared).trim();
if (!/^\w+$/.test(local)) continue;
out.set(local, { module: from[1]!, name: declared });
}
}
return out;
}
/** What an index needs from the editor: a file's lines, and a module's file. */
export interface InheritanceDeps {
readFiles: (paths: readonly string[]) => Promise<Map<string, string[] | null>>;
/** `from a.b import X`: the file `a.b` names, `.x` resolved relative to `from`. */
moduleFile?: (module: string, from: string) => Promise<string | null>;
}
/** Resolves the base names the graph stores to class declarations. */
export interface InheritanceIndex {
/** The graph's classes by name. */
byName: ReadonlyMap<string, ClassInheritance[]>;
/** A base that names one class, or a class of the subclass's own file. */
exact(name: string, from: string): ClassInheritance[];
/** `exact`, else the declaration the file's imports point at (empty when unknown). */
resolve(name: string, from: string): Promise<ClassInheritance[]>;
/** Reads the imports of `paths` in one batch, ahead of the `resolve` calls that need them. */
readImports(paths: readonly string[]): Promise<void>;
}
/**
* Index a graph for lookups. A base name is only trusted when it names one class in the
* project or a class of the subclass's own file: a `Config` declared in hundreds of
* modules must not make every one of them a subclass of every other. When several files
* declare it, the subclass file's own imports decide (`from x.y import Config`).
*/
export function inheritanceIndex(graph: readonly ClassInheritance[], deps: InheritanceDeps): InheritanceIndex {
const byName = new Map<string, ClassInheritance[]>();
for (const cls of graph) byName.set(cls.name, [...(byName.get(cls.name) ?? []), cls]);
const imports = new Map<string, Promise<Map<string, ImportBinding> | null>>();
const answers = new Map<string, Promise<ClassInheritance[]>>();
const exact = (name: string, from: string): ClassInheritance[] => {
const found = byName.get(name) ?? [];
return found.length <= 1 ? found : found.filter((c) => c.path === from);
};
const bindingsOf = (from: string): Promise<Map<string, ImportBinding> | null> => {
let pending = imports.get(from);
if (!pending) {
pending = deps
.readFiles([from])
.then((files) => {
const lines = files.get(from);
return lines ? importBindings(lines) : null;
})
.catch(() => null);
imports.set(from, pending);
}
return pending;
};
return {
byName,
exact,
readImports: async (paths) => {
// Without a module lookup an import never resolves a base: nothing to read for it.
const unread = deps.moduleFile ? [...new Set(paths)].filter((p) => !imports.has(p)) : [];
if (!unread.length) return;
const files = deps.readFiles(unread).catch(() => new Map<string, string[] | null>());
for (const p of unread) {
imports.set(
p,
files.then((f) => {
const lines = f.get(p);
return lines ? importBindings(lines) : null;
}),
);
}
await files;
},
resolve: (name, from) => {
const found = exact(name, from);
if (found.length || !deps.moduleFile) return Promise.resolve(found);
const moduleFile = deps.moduleFile;
// No class of that name at all: it may be an import alias (`import X as Y`).
const key = `${from}\u0000${name}`;
let pending = answers.get(key);
if (!pending) {
pending = (async () => {
const binding = (await bindingsOf(from))?.get(name);
// The graph has no class under the imported name: no module lookup can find one.
if (!binding || !byName.get(binding.name)?.length) return [];
// `from . import X` names the package itself: resolve its `__init__`.
const module = binding.module;
const named = /^\.+$/.test(module) ? `${module.endsWith(".") ? module : `${module}.`}__init__` : module;
const file = await moduleFile(named, from).catch(() => null);
// The declaration carries the imported (pre-alias) name.
return file ? (byName.get(binding.name) ?? []).filter((c) => c.path === file) : [];
})();
answers.set(key, pending);
}
return pending;
},
};
}
/** The graph indexed once per project, with what every consumer derives from it. */
export interface PythonIndex {
classes: readonly ClassInheritance[];
index: InheritanceIndex;
/**
* Direct subclasses, keyed by [`declarationKey`] of the base they write. Built on first use:
* it resolves every base of the project, which supertype lookups never need.
*/
subclasses: () => Promise<Map<string, ClassInheritance[]>>;
}
let indexCache: { source: Promise<ClassInheritance[]>; pending: Promise<PythonIndex> } | null = null;
/**
* The project's graph indexed once, shared by every consumer: the gutter, the type
* hierarchy and the inspections all need the same name map, resolver and subclass map,
* and rebuilding them per call (or per feature) would walk the graph again.
*/
/** What the project index reads: file lines and the file a module names. */
const PROJECT_DEPS: InheritanceDeps = {
readFiles: readManyFileLines,
moduleFile: (module, from) => lspRequest(() => commands.navResolveModule(module, from)),
};
export function projectPythonIndex(): Promise<PythonIndex> {
const source = pythonInheritanceGraph();
if (!indexCache || indexCache.source !== source) {
const pending = source.then(async (graph) => {
const index = inheritanceIndex(graph, PROJECT_DEPS);
let subclasses: Promise<Map<string, ClassInheritance[]>> | null = null;
return { classes: graph, index, subclasses: () => (subclasses ??= subclassMap(index)) };
});
indexCache = { source, pending };
pending.catch(() => {
if (indexCache?.pending === pending) indexCache = null;
});
}
return indexCache.pending;
}
/** Identifies one class declaration in the graph. */
export function declarationKey(cls: ClassInheritance): string {
return `${cls.path}:${cls.line}:${cls.col}`;
}
/** The class declaration itself as a navigation target (its name identifier). */
export function classLocation(cls: ClassInheritance): Location {
const start = { line: cls.line, col: cls.col };
return { path: cls.path, range: { start, end: start } };
}
/** The method `method` (bare name) directly inside the class at `cls`'s position. */
export function methodInOutline(
outline: readonly OutlineItem[],
cls: ClassInheritance,
method: string,
): OutlineItem | null {
const find = (items: readonly OutlineItem[]): OutlineItem | null => {
for (const it of items) {
if (
it.kind === "class" &&
it.selection_range.start.line === cls.line &&
it.selection_range.start.col === cls.col
) {
return it.children.find((ch) => ch.kind === "method" && baseName(ch.name) === method) ?? null;
}
const deeper = find(it.children);
if (deeper) return deeper;
}
return null;
};
return find(outline);
}
/** Direct subclasses, keyed by [`declarationKey`] of the base they write. */
export async function subclassMap(index: InheritanceIndex): Promise<Map<string, ClassInheritance[]>> {
const pairs: Array<[ClassInheritance, string]> = [];
for (const sameNamed of index.byName.values()) {
for (const cls of sameNamed) for (const base of new Set(cls.bases)) pairs.push([cls, base]);
}
// The files whose imports decide a base: read together, not one request per class.
await index.readImports(pairs.filter(([cls, base]) => !index.exact(base, cls.path).length).map(([cls]) => cls.path));
const resolved = await Promise.all(pairs.map(([cls, base]) => index.resolve(base, cls.path)));
const out = new Map<string, ClassInheritance[]>();
pairs.forEach(([cls], i) => {
for (const decl of resolved[i]!) {
const key = declarationKey(decl);
out.set(key, [...(out.get(key) ?? []), cls]);
}
});
return out;
}
/** The declarations `cls` directly inherits from, in the order it writes them. */
export async function baseDeclarations(index: InheritanceIndex, cls: ClassInheritance): Promise<ClassInheritance[]> {
const out: ClassInheritance[] = [];
for (const base of cls.bases) out.push(...(await index.resolve(base, cls.path)));
return out;
}
/**
* Every ancestor of `cls`, nearest first (breadth-first, each declaration once), up to
* `depth` levels. The order within a level follows the source's base order.
*/
export async function ancestors(
index: InheritanceIndex,
cls: ClassInheritance,
depth = 8,
): Promise<ClassInheritance[]> {
const seen = new Set<string>([declarationKey(cls)]);
const out: ClassInheritance[] = [];
let level = await baseDeclarations(index, cls);
for (let d = 0; d < depth && level.length; d++) {
const next: ClassInheritance[] = [];
for (const parent of level) {
if (!seen.add(declarationKey(parent))) continue;
out.push(parent);
next.push(...(await baseDeclarations(index, parent)));
}
level = next;
}
return out;
}
+1 -250
Переглянути файл
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
IpcError,
type ClassInheritance,
type HierarchyItem,
type Location,
type OutlineItem,
@@ -34,7 +33,6 @@ import {
openMarkerTargets,
type MarkerDeps,
} from "./implementationGutter";
import { importBindings } from "./inheritanceGraph";
const ALL = enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] });
@@ -210,9 +208,8 @@ function server(
impls: Record<string, Location[]>,
supers: Record<string, HierarchyItem[]>,
subtypes: Record<string, HierarchyItem[]> = {},
inheritance?: ClassInheritance[],
) {
const calls = { implementations: [] as Pos[][], supertypes: 0, subtypes: 0, inheritance: 0 };
const calls = { implementations: [] as Pos[][], supertypes: 0, subtypes: 0 };
const outlines: Record<string, OutlineItem[]> = {
[SHAPE_PATH]: SHAPE_OUTLINE,
"/p/Circle.java": CIRCLE_OUTLINE,
@@ -237,44 +234,6 @@ function server(
outline: async (p) => outlines[p] ?? [],
},
readFiles: async (paths) => new Map(paths.map((p) => [p, FILES[p] ?? null])),
inheritance: inheritance
? async () => {
calls.inheritance++;
return inheritance;
}
: undefined,
});
return { calls, deps };
}
/** A Python project: the server answers neither implementations nor a type hierarchy. */
function pythonServer(
inheritance: ClassInheritance[],
outlines: Record<string, OutlineItem[]>,
files: Record<string, string[]> = {},
modules: Record<string, string> = {},
) {
const calls = { implementations: 0, inheritance: 0, modules: [] as string[] };
const deps = (): MarkerDeps => ({
implementations: async () => {
calls.implementations++;
throw new IpcError({ kind: "unavailable", message: "basedpyright doesn't support implementation" });
},
supers: {
supertypes: async () => {
throw new IpcError({ kind: "unavailable", message: "basedpyright doesn't support type hierarchy" });
},
outline: async (p) => outlines[p] ?? [],
},
readFiles: async (paths) => new Map(paths.map((p) => [p, files[p] ?? null])),
moduleFile: async (module, from) => {
calls.modules.push(`${module}@${from}`);
return modules[`${module}@${from}`] ?? null;
},
inheritance: async () => {
calls.inheritance++;
return inheritance;
},
});
return { calls, deps };
}
@@ -678,211 +637,3 @@ describe("overriders of a type's methods from its subtypes", () => {
]);
});
});
describe("python inheritance fallback", () => {
// a.py: `class A` (line 0) with `m` (line 1), then `class B(A)` (line 3) with `m` (line 4).
const outline = [
item("A", "class", 0, 2, [item("m", "method", 1, 1)], 6),
item("B", "class", 3, 5, [item("m", "method", 4, 4)], 6),
];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "A", bases: [] },
{ path: "/p/a.py", line: 3, col: 6, name: "B", bases: ["A"] },
];
it("marks the subclass and the overridden method with no type hierarchy", async () => {
const { deps, calls } = pythonServer(graph, { "/p/a.py": outline });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/a.py", candidates, ALL, deps(), cache);
const markers = cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind, m.targets.map((t) => t.path)]);
expect(markers).toEqual([
// The class B subclasses A...
[0, "overridden", ["/p/a.py"]],
// ...and its `m` overrides A's `m`.
[1, "overridden", ["/p/a.py"]],
[4, "overriding", ["/p/a.py"]],
]);
// The graph is asked once per cache, not once per candidate.
expect(calls.inheritance).toBe(1);
});
it("survives an unavailable implementation provider (the fallback keeps its candidates)", async () => {
const { deps, calls } = pythonServer(graph, { "/p/a.py": outline });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/a.py", candidates, ALL, deps(), cache);
// The server said "unavailable", but the graph still answered ↓ and ↑.
expect(calls.implementations).toBeGreaterThan(0);
expect(cache.unsupported.size).toBe(0);
});
it("adds the graph's cross-project subclasses to a non-empty server answer", async () => {
// The server (single root) answers the local subclass only; the attached-project
// subclass is known to the IDE's graph and must join the list.
const outline = [item("A", "class", 0, 2, [], 6), item("B", "class", 3, 5, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "A", bases: [] },
{ path: "/p/a.py", line: 3, col: 6, name: "B", bases: ["A"] },
{ path: "/p/attached/a.py", line: 0, col: 6, name: "C", bases: ["A"] },
];
const { deps } = server({ "/p/a.py:0": [loc("/p/a.py", 3)] }, {}, {}, graph);
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/a.py", candidates, ALL, deps("/p/a.py"), cache);
const targets = cachedMarkers(candidates, ALL, cache)
.filter((m) => m.line === 0)
.flatMap((m) => m.targets.map((t) => t.path));
expect(targets).toEqual(["/p/a.py", "/p/attached/a.py"]);
});
it("ignores a bare base name that many classes in the project share", async () => {
// `Config` is declared in hundreds of modules: no edge, so no marker.
const outline = [item("Child", "class", 0, 2, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/c.py", line: 0, col: 6, name: "Child", bases: ["Config"] },
];
const { deps } = pythonServer(graph, { "/p/c.py": outline });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/c.py", candidates, ALL, deps(), cache);
expect(cachedMarkers(candidates, ALL, cache)).toEqual([]);
});
it("trusts a shared base name when the subclass's own file declares it", async () => {
const outline = [item("Config", "class", 0, 1, [], 6), item("Child", "class", 3, 5, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/c.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/other.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Config"] },
];
const { deps } = pythonServer(graph, { "/p/c.py": outline });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/c.py", candidates, ALL, deps(), cache);
expect(cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind])).toEqual([[0, "overridden"]]);
});
it("resolves an ambiguous base name through the subclass file's imports", async () => {
// Two modules declare `Config`; `from pkg.b import Config` picks one of them, so
// only b.py's Config is marked as subclassed.
const child = [item("Child", "class", 3, 4, [], 6)];
const parentOutline = [item("Config", "class", 0, 1, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Config"] },
];
const files = { "/p/c.py": ["from pkg.b import Config", "", "", "class Child(Config):"] };
const modules = { "pkg.b@/p/c.py": "/p/b.py" };
const { deps, calls } = pythonServer(graph, { "/p/c.py": child, "/p/b.py": parentOutline }, files, modules);
const candidates = markerCandidates(parentOutline, () => "");
const cache = emptyCache();
await fillMarkers("/p/b.py", candidates, ALL, deps(), cache);
expect(calls.modules).toEqual(["pkg.b@/p/c.py"]);
expect(cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind, m.targets.map((t) => t.path)])).toEqual([
[0, "overridden", ["/p/c.py"]],
]);
});
it("resolves a base through an aliased import", async () => {
// c.py: `class Child(Cfg)` (line 3) with `m` (line 4); b.py's Config declares `m` too.
const outline = [item("Child", "class", 3, 5, [item("m", "method", 4, 4)], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Cfg"] },
];
const files = {
"/p/c.py": ["from pkg.b import Config as Cfg", "", "", "class Child(Cfg):", " def m(self): ..."],
};
const outlines = {
"/p/c.py": outline,
"/p/a.py": [item("Config", "class", 0, 2, [item("m", "method", 1, 1)], 6)],
"/p/b.py": [item("Config", "class", 0, 2, [item("m", "method", 1, 1)], 6)],
};
const { deps } = pythonServer(graph, outlines, files, { "pkg.b@/p/c.py": "/p/b.py" });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/c.py", candidates, ALL, deps(), cache);
expect(cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind, m.targets.map((t) => t.path)])).toEqual([
[4, "overriding", ["/p/b.py"]],
]);
});
it("reads no other file's imports for a shared base this file does not declare", async () => {
const outline = [item("Other", "class", 0, 1, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Config", "BaseModel"] },
{ path: "/p/d.py", line: 0, col: 6, name: "Other", bases: [] },
];
const files = { "/p/c.py": ["from pkg.b import Config", "", "", "class Child(Config, BaseModel):"] };
const { deps, calls } = pythonServer(graph, { "/p/d.py": outline }, files, { "pkg.b@/p/c.py": "/p/b.py" });
const cache = emptyCache();
await fillMarkers(
"/p/d.py",
markerCandidates(outline, () => ""),
ALL,
deps(),
cache,
);
expect(calls.modules).toEqual([]);
});
it("keeps ignoring a shared base name when no import names it", async () => {
const outline = [item("Child", "class", 0, 2, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/c.py", line: 0, col: 6, name: "Child", bases: ["Config"] },
];
// No import of `Config`: the name stays ambiguous and no edge is invented.
const files = { "/p/c.py": ["import os", "", "class Child(Config):"] };
const { deps } = pythonServer(graph, { "/p/c.py": outline }, files);
const cache = emptyCache();
await fillMarkers(
"/p/c.py",
markerCandidates(outline, () => ""),
ALL,
deps(),
cache,
);
expect(
cachedMarkers(
markerCandidates(outline, () => ""),
ALL,
cache,
),
).toEqual([]);
});
it("reads plain, aliased and parenthesized imports", () => {
const bindings = importBindings([
"from pkg.mod import A, B as Bee",
"from pkg import (",
" C,",
" D as Dee,",
")",
"import x.y as xy",
"if TYPE_CHECKING:",
]);
expect(Object.fromEntries(bindings)).toEqual({
A: { module: "pkg.mod", name: "A" },
Bee: { module: "pkg.mod", name: "B" },
C: { module: "pkg", name: "C" },
Dee: { module: "pkg", name: "D" },
});
});
it("keeps the existing server path when no inheritance graph is injected", async () => {
const { deps } = server({}, {});
const candidates = markerCandidates([item("Foo", "class", 0, 2, [item("m", "method", 1, 1)], 6)], () => "");
const cache = emptyCache();
await fillMarkers("/p/a.py", candidates, ALL, deps("/p/a.py"), cache);
expect(cachedMarkers(candidates, ALL, cache)).toEqual([]);
});
});
-160
Переглянути файл
@@ -1,160 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ClassInheritance, OutlineItem } from "@/platform/ipc";
import { superMethods } from "./structureNav";
import { inheritanceIndex, subclassMap } from "./inheritanceGraph";
import {
classAt,
classItem,
isPythonPath,
pythonAncestors,
pythonDescendants,
pythonHierarchy,
pythonImplementationTargets,
pythonSuperDeps,
pythonSupertypes,
} from "./pythonHierarchy";
const cls = (path: string, line: number, name: string, bases: string[] = []): ClassInheritance => ({
path,
line,
col: 6,
name,
bases,
});
// `from .animal import Base` in dog.py: two modules declare a `Base`, the import picks one.
const GRAPH = [
cls("/p/animal.py", 0, "Base"),
cls("/p/other.py", 0, "Base"),
cls("/p/living.py", 0, "Living"),
cls("/p/dog.py", 2, "Dog", ["Base"]),
cls("/p/puppy.py", 4, "Puppy", ["Dog"]),
];
const FILES: Record<string, string[]> = {
"/p/dog.py": ["from .animal import Base", "", "class Dog(Base):", " def bark(self):\n", " pass\n"],
};
const hierarchy = pythonHierarchy(GRAPH, {
readFiles: async (paths) => new Map(paths.map((p) => [p, FILES[p] ?? null])),
// `.animal` names the animal module, as the backend's module resolver would.
moduleFile: async (module) => (module === ".animal" ? "/p/animal.py" : null),
});
const item = (name: string, kind: OutlineItem["kind"], start: number, end: number, children: OutlineItem[] = []) => ({
name,
detail: null,
kind,
range: { start: { line: start, col: 0 }, end: { line: end, col: 1 } },
selection_range: { start: { line: start, col: 6 }, end: { line: start, col: 6 + name.length } },
children,
});
const OUTLINES: Record<string, OutlineItem[]> = {
"/p/dog.py": [item("Dog", "class", 2, 6, [item("bark", "method", 3, 4)])],
"/p/animal.py": [item("Base", "class", 0, 5, [item("bark", "method", 1, 2), item("eat", "method", 3, 4)])],
"/p/living.py": [item("Living", "class", 0, 5, [item("age", "field", 1, 2)])],
"/p/puppy.py": [item("Puppy", "class", 4, 9, [item("bark", "method", 5, 6)])],
};
const outlineOf = async (path: string) => OUTLINES[path] ?? [];
describe("Python inheritance graph helpers", () => {
it("knows Python sources and finds the class declared at a position", () => {
expect(isPythonPath("/p/dog.py")).toBe(true);
expect(isPythonPath("/p/dog.pyi")).toBe(true);
expect(isPythonPath("/p/Dog.java")).toBe(false);
expect(classAt(hierarchy, "/p/dog.py", { line: 2, col: 6 })?.name).toBe("Dog");
expect(classAt(hierarchy, "/p/dog.py", { line: 2, col: 0 })?.name).toBe("Dog");
expect(classAt(hierarchy, "/p/dog.py", { line: 7, col: 6 })).toBeNull();
});
it("resolves an ambiguous base name through the file's imports", async () => {
const dog = classAt(hierarchy, "/p/dog.py", { line: 2, col: 6 })!;
expect((await pythonSupertypes(hierarchy, dog)).map((c) => c.path)).toEqual(["/p/animal.py"]);
expect((await pythonAncestors(hierarchy, dog)).map((c) => c.name)).toEqual(["Base"]);
// A Base in another module is no supertype of Dog.
const other = classAt(hierarchy, "/p/other.py", { line: 0, col: 6 })!;
expect(await pythonDescendants(hierarchy, other)).toEqual([]);
});
it("lists every descendant level by level", async () => {
const base = classAt(hierarchy, "/p/animal.py", { line: 0, col: 6 })!;
expect((await pythonDescendants(hierarchy, base)).map((c) => c.name)).toEqual(["Dog", "Puppy"]);
});
it("shapes graph classes as hierarchy items at their name", () => {
const dog = classAt(hierarchy, "/p/dog.py", { line: 2, col: 6 })!;
expect(classItem(dog)).toMatchObject({
name: "Dog",
kind: "class",
path: "/p/dog.py",
selectionRange: { start: { line: 2, col: 6 }, end: { line: 2, col: 9 } },
});
});
});
describe("Python Super Method", () => {
it("finds the nearest super method through the graph's supertypes", async () => {
const deps = pythonSuperDeps(hierarchy, outlineOf);
const locs = await superMethods("/p/dog.py", { line: 2, col: 6 }, "bark", deps);
// Dog inherits from Base, which declares bark.
expect(locs.map((l) => `${l.path}:${l.range.start.line}`)).toEqual(["/p/animal.py:1"]);
});
it("answers the super class of a type with no method at the caret", async () => {
const items = await pythonSuperDeps(hierarchy, outlineOf).supertypes("/p/dog.py", { line: 2, col: 6 });
expect(items.map((i) => i.name)).toEqual(["Base"]);
});
});
describe("Python Go to Implementation", () => {
it("targets every descendant class of the class at the caret", async () => {
const targets = await pythonImplementationTargets(hierarchy, "/p/dog.py", { line: 2, col: 6 }, outlineOf);
expect(targets.map((l) => `${l.path}:${l.range.start.line}`)).toEqual(["/p/puppy.py:4"]);
});
it("targets the overriding methods of a method at the caret", async () => {
const targets = await pythonImplementationTargets(hierarchy, "/p/dog.py", { line: 3, col: 8 }, outlineOf);
expect(targets.map((l) => `${l.path}:${l.range.start.line}`)).toEqual(["/p/puppy.py:5"]);
});
it("keeps the server's answer useful when the project graph knows nothing", async () => {
// The graph's own answer: a class without descendants has no implementation.
const targets = await pythonImplementationTargets(hierarchy, "/p/puppy.py", { line: 4, col: 6 }, outlineOf);
expect(targets).toEqual([]);
});
});
describe("the project's subclass map", () => {
it("reads the deciding imports in one batch and looks up only modules that can name a class", async () => {
const graph = [
cls("/p/a.py", 0, "Base"),
cls("/p/b.py", 0, "Base"),
cls("/p/c.py", 0, "Child", ["Base"]),
cls("/p/d.py", 0, "Model", ["BaseModel"]),
cls("/p/e.py", 0, "Other", ["Base"]),
];
const files: Record<string, string[]> = {
"/p/c.py": ["from .a import Base"],
"/p/d.py": ["from pydantic import BaseModel"],
"/p/e.py": ["from .b import Base"],
};
const reads: string[][] = [];
const lookups: string[] = [];
const index = inheritanceIndex(graph, {
readFiles: async (paths) => {
reads.push([...paths]);
return new Map(paths.map((p) => [p, files[p] ?? null]));
},
moduleFile: async (module) => {
lookups.push(module);
return module === ".a" ? "/p/a.py" : module === ".b" ? "/p/b.py" : null;
},
});
const map = await subclassMap(index);
expect(reads).toEqual([["/p/c.py", "/p/d.py", "/p/e.py"]]);
// pydantic's BaseModel is no class of the project: its module is never looked up.
expect(lookups.sort()).toEqual([".a", ".b"]);
expect(map.get("/p/a.py:0:6")?.map((c) => c.name)).toEqual(["Child"]);
expect(map.get("/p/b.py:0:6")?.map((c) => c.name)).toEqual(["Other"]);
});
});
-177
Переглянути файл
@@ -1,177 +0,0 @@
/**
* Type hierarchy, super methods and implementations for Python from the IDE's inheritance
* graph. basedpyright and pyrefly declare no `typeHierarchyProvider` and analyse a single
* root, so those features cannot be answered by the server; the graph knows every
* `class X(Base)` of the project, attached dependency roots included.
*/
import { type ClassInheritance, type HierarchyItem, type Location, type OutlineItem, type Pos } from "@/platform/ipc";
import {
ancestors,
baseDeclarations,
classLocation,
declarationKey,
inheritanceIndex,
methodInOutline,
projectPythonIndex,
subclassMap,
type InheritanceDeps,
type InheritanceIndex,
} from "./inheritanceGraph";
import { baseName, memberAt, type SuperDeps } from "./structureNav";
/** The project's classes with their base names, indexed for lookups. */
export interface PythonHierarchy {
index: InheritanceIndex;
/** Classes by `path:line`, so a position lookup does not scan the whole project graph. */
byLine: ReadonlyMap<string, ClassInheritance[]>;
/** Direct subclasses by [`declarationKey`] of the base they write (from the shared index). */
subclasses?: () => Promise<ReadonlyMap<string, ClassInheritance[]>>;
}
/** Indexes `classes`; `deps` reads the files an ambiguous base name's imports point at. */
export function pythonHierarchy(classes: readonly ClassInheritance[], deps: InheritanceDeps): PythonHierarchy {
const byLine = new Map<string, ClassInheritance[]>();
for (const cls of classes) {
const key = `${cls.path}:${cls.line}`;
byLine.set(key, [...(byLine.get(key) ?? []), cls]);
}
return { index: inheritanceIndex(classes, deps), byLine };
}
/** Whether `path` is a Python source the inheritance graph covers. */
export const isPythonPath = (path: string) => /\.pyi?$/.test(path);
let cache: { classes: readonly ClassInheritance[]; byLine: Map<string, ClassInheritance[]> } | null = null;
/** The open project's graph, indexed once and shared with the gutter and the inspections. */
export async function projectPythonHierarchy(): Promise<PythonHierarchy> {
const project = await projectPythonIndex();
if (cache?.classes !== project.classes) {
const byLine = new Map<string, ClassInheritance[]>();
for (const cls of project.classes) {
const key = `${cls.path}:${cls.line}`;
byLine.set(key, [...(byLine.get(key) ?? []), cls]);
}
cache = { classes: project.classes, byLine };
}
return { index: project.index, byLine: cache.byLine, subclasses: project.subclasses };
}
/** The graph's class declared on `pos`'s line in `path` (the column picks between two on one line). */
export function classAt(hierarchy: PythonHierarchy, path: string, pos: Pos): ClassInheritance | null {
const onLine = hierarchy.byLine.get(`${path}:${pos.line}`) ?? [];
return onLine.find((c) => c.col === pos.col) ?? (onLine.length === 1 ? onLine[0]! : null);
}
/** A graph class as the type-hierarchy / supertype item a language server would return. */
export function classItem(cls: ClassInheritance): HierarchyItem {
const range = { start: { line: cls.line, col: cls.col }, end: { line: cls.line, col: cls.col + cls.name.length } };
return {
name: cls.name,
kind: "class",
detail: null,
path: cls.path,
range,
selectionRange: range,
fromRanges: [],
};
}
/** The declarations `cls` directly inherits from, in the order it writes them. */
export const pythonSupertypes = (hierarchy: PythonHierarchy, cls: ClassInheritance) =>
baseDeclarations(hierarchy.index, cls);
/** The Python supertypes of the type at `pos` in `path`, as hierarchy items. */
export async function pythonSupertypeItems(
hierarchy: PythonHierarchy,
path: string,
pos: Pos,
): Promise<HierarchyItem[]> {
const cls = classAt(hierarchy, path, pos);
return cls ? (await pythonSupertypes(hierarchy, cls)).map(classItem) : [];
}
const subclassMaps = new WeakMap<PythonHierarchy, Promise<Map<string, ClassInheritance[]>>>();
/** Direct subclasses: the shared index's map, or one derived for a hand-built hierarchy. */
function subclassesOf(hierarchy: PythonHierarchy): Promise<ReadonlyMap<string, ClassInheritance[]>> {
if (hierarchy.subclasses) return hierarchy.subclasses();
let map = subclassMaps.get(hierarchy);
if (!map) subclassMaps.set(hierarchy, (map = subclassMap(hierarchy.index)));
return map;
}
/** The declarations directly inheriting from `cls`. */
export async function pythonSubtypes(hierarchy: PythonHierarchy, cls: ClassInheritance): Promise<ClassInheritance[]> {
return (await subclassesOf(hierarchy)).get(declarationKey(cls)) ?? [];
}
/** Every ancestor of `cls`, nearest first. */
export const pythonAncestors = (hierarchy: PythonHierarchy, cls: ClassInheritance) => ancestors(hierarchy.index, cls);
/** Every descendant of `cls`, nearest level first, each declaration once. */
export async function pythonDescendants(
hierarchy: PythonHierarchy,
cls: ClassInheritance,
depth = 8,
): Promise<ClassInheritance[]> {
const subclasses = await subclassesOf(hierarchy);
const seen = new Set<string>([declarationKey(cls)]);
const out: ClassInheritance[] = [];
let level = [cls];
for (let d = 0; d < depth && level.length; d++) {
const next: ClassInheritance[] = [];
for (const parent of level) {
for (const sub of subclasses.get(declarationKey(parent)) ?? []) {
if (!seen.add(declarationKey(sub))) continue;
out.push(sub);
next.push(sub);
}
}
level = next;
}
return out;
}
/** Super Method (⌘U) deps backed by the graph, for a language server without a type hierarchy. */
export function pythonSuperDeps(
hierarchy: PythonHierarchy,
outline: (path: string) => Promise<OutlineItem[]>,
): SuperDeps {
return {
supertypes: (path, pos) => pythonSupertypeItems(hierarchy, path, pos),
outline,
};
}
/**
* Go to Implementation targets of the Python class or method at `pos` in `path`: the classes
* inheriting from it, or the methods overriding it, anywhere below it in the graph.
*/
export async function pythonImplementationTargets(
hierarchy: PythonHierarchy,
path: string,
pos: Pos,
outline: (path: string) => Promise<OutlineItem[]>,
depth = 8,
): Promise<Location[]> {
const outlines = new Map<string, Promise<OutlineItem[]>>();
const outlineOf = (p: string) => {
let o = outlines.get(p);
if (!o) outlines.set(p, (o = outline(p).catch(() => [] as OutlineItem[])));
return o;
};
const member = memberAt(await outlineOf(path), pos);
if (!member.type) return [];
const cls = classAt(hierarchy, path, member.type.selection_range.start);
if (!cls) return [];
const descendants = await pythonDescendants(hierarchy, cls, depth);
if (!member.method) return descendants.map(classLocation);
const name = baseName(member.method.name);
const targets: Location[] = [];
for (const sub of descendants) {
const method = methodInOutline(await outlineOf(sub.path), sub, name);
if (method) targets.push({ path: sub.path, range: method.selection_range });
}
return targets;
}
-34
Переглянути файл
@@ -189,40 +189,6 @@
.modelEffortPopup .modelList {
max-height: 216px;
}
.catalogFooter {
margin-top: 6px;
padding: 8px 12px 2px;
border-top: 1px solid var(--border);
}
.catalogNote,
.catalogError {
margin: 0;
padding: 6px 12px;
color: var(--fg-muted);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.catalogError {
color: var(--danger);
}
.catalogFooter .catalogError {
padding: 0 0 8px;
}
.refreshModels {
padding: 4px 0;
border: 0;
background: transparent;
color: var(--fg);
font: inherit;
cursor: pointer;
}
.refreshModels:hover:not(:disabled) {
color: var(--fg-strong);
}
.refreshModels:disabled {
color: var(--fg-muted);
cursor: default;
}
.effortHeader {
display: flex;
align-items: baseline;
-12
Переглянути файл
@@ -133,8 +133,6 @@ export interface ComposerProps {
onSubmit: (now: boolean) => void;
disabled?: boolean;
autoFocus?: boolean;
/** A request from this host to focus its input without changing the selection. */
focusRequest?: number;
/** Left of the bottom row: agent / model / mode pickers. */
toolbar?: ReactNode;
/** Right of the bottom row: send / stop buttons. */
@@ -348,16 +346,6 @@ export function Composer(props: ComposerProps) {
});
}, [focus]);
const previousFocusRequest = useRef(props.focusRequest);
useEffect(() => {
if (previousFocusRequest.current === props.focusRequest) return;
previousFocusRequest.current = props.focusRequest;
const view = viewRef.current;
if (!props.focusRequest || props.disabled || !view) return;
view.focus();
view.dispatch({ scrollIntoView: true });
}, [props.focusRequest, props.disabled]);
// `@` completion: project file search, mapped into the task's directory.
useEffect(() => {
if (completion?.kind !== "file") return;
+11 -73
Переглянути файл
@@ -4,7 +4,7 @@
* list and an effort slider; with a mode and an on/off permissions option (Allow All),
* the mode menu carries the switch. Names and descriptions are the agent's own.
*/
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { useRef, useState, type CSSProperties } from "react";
import clsx from "clsx";
import type { AgentConfigOption } from "@/bindings";
import { useTx } from "@/platform/i18n";
@@ -48,45 +48,25 @@ function switchValues(option: AgentConfigOption): { on: string; off: string } |
const selected = (option: AgentConfigOption, value: string | null) =>
option.values.find((v) => v.value === value) ?? null;
interface ModelRefresh {
refreshing: boolean;
retryAtMs: number | null;
error: string | null;
onRefresh: () => void;
}
export function ConfigPickers({
config,
values,
onChange,
modelRefresh,
}: {
config: readonly AgentConfigOption[];
/** Chosen values that override `current`, by option id. */
values?: Record<string, string>;
onChange: (option: string, value: string) => void;
/** Only New Task supplies catalog discovery; session pickers retain their own settings. */
modelRefresh?: ModelRefresh;
}) {
const tx = useTx();
const [open, setOpen] = useState(false);
const anchor = useRef<HTMLButtonElement>(null);
const options = mainOptions(config);
if (modelRefresh && !options.some((o) => o.category === "model"))
options.unshift({
id: "model",
name: "Model",
category: "model",
description: null,
current: null,
values: [],
via: "option",
});
const valueOf = (o: AgentConfigOption) => values?.[o.id] ?? o.current ?? null;
const model = options.find((o) => o.category === "model");
const effort = config.find((o) => o.category === "thought_level" && o.values.length > 0);
const modelConfigs = modelConfigOptions(config);
const combinedModel = model && (modelRefresh || effort || modelConfigs.length > 0);
const combinedModel = model && (effort || modelConfigs.length > 0);
const tuckedModelConfig = new Set(modelConfigs.map((o) => o.id));
const mode = options.find((o) => o.category === "mode");
const allowAll = mode && options.find((o) => switchValues(o));
@@ -112,7 +92,6 @@ export function ConfigPickers({
modelConfigs={modelConfigs}
valueOf={valueOf}
onChange={onChange}
modelRefresh={modelRefresh}
/>
) : o === mode && allowAll ? (
<ModePicker
@@ -227,7 +206,6 @@ function ModelEffortPicker({
modelConfigs,
valueOf,
onChange,
modelRefresh,
}: {
model: AgentConfigOption;
modelValue: string | null;
@@ -236,15 +214,11 @@ function ModelEffortPicker({
modelConfigs: AgentConfigOption[];
valueOf: (o: AgentConfigOption) => string | null;
onChange: (option: string, value: string) => void;
modelRefresh?: ModelRefresh;
}) {
const tx = useTx();
const [open, setOpen] = useState(false);
const anchor = useRef<HTMLButtonElement>(null);
const unavailable = modelRefresh && modelValue !== null && !selected(model, modelValue);
const modelName = unavailable
? tx`${modelValue} (unavailable)`
: (selected(model, modelValue)?.name ?? tx("Default"));
const modelName = selected(model, modelValue)?.name ?? tx("Default");
const effortName = effort ? (selected(effort, effortValue)?.name ?? tx("Default")) : null;
const modelConfigSummary = modelConfigs
.map((o) => {
@@ -292,11 +266,6 @@ function ModelEffortPicker({
aria-label={tx("Model & effort settings")}
className={styles.modelEffortPopup}
>
{unavailable && (
<p className={styles.catalogError} role="alert">
<T>Choose another model before starting.</T>
</p>
)}
{effort && (
<div className={styles.effortBlock}>
<div className={styles.effortHeader}>
@@ -312,50 +281,19 @@ function ModelEffortPicker({
<ConfigPicker option={o} value={valueOf(o)} onChange={onChange} framed />
</div>
))}
{model.values.length > 0 || !modelRefresh ? (
<SelectListbox
aria-label={tx(model.name)}
value={modelValue}
options={configPickerOptions(model)}
searchable
listClassName={styles.modelList}
onChoose={(v) => onChange(model.id, v)}
/>
) : (
<p className={styles.catalogNote} role="status">
{modelRefresh?.refreshing ? <T>Loading models…</T> : <T>No models reported by this agent.</T>}
</p>
)}
{modelRefresh && <ModelRefreshFooter key={modelRefresh.retryAtMs} {...modelRefresh} />}
<SelectListbox
aria-label={tx(model.name)}
value={modelValue}
options={configPickerOptions(model)}
searchable
listClassName={styles.modelList}
onChoose={(v) => onChange(model.id, v)}
/>
</Popup>
</>
);
}
function ModelRefreshFooter({ refreshing, retryAtMs, error, onRefresh }: ModelRefresh) {
const tx = useTx();
const [now, setNow] = useState(Date.now);
const remaining = retryAtMs == null ? 0 : Math.max(0, Math.ceil((retryAtMs - now) / 1000));
const coolingDown = remaining > 0;
useEffect(() => {
if (!coolingDown) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [retryAtMs, coolingDown]);
return (
<div className={styles.catalogFooter}>
{error && (
<p className={styles.catalogError} role="alert">
{tx`Could not refresh models: ${error}`}
</p>
)}
<button type="button" className={styles.refreshModels} disabled={refreshing || coolingDown} onClick={onRefresh}>
{refreshing ? tx("Refreshing…") : coolingDown ? tx`Refresh in ${remaining}s` : tx("Refresh models")}
</button>
</div>
);
}
/** A stepped slider over the agent's effort levels, in the order the agent lists them. */
function EffortSlider({
option,
+2 -13
Переглянути файл
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { AgentConfigOption, AgentProvider, NewAgentTask } from "@/bindings";
import { useProject } from "@/platform/project";
import { HomeEditor } from "./HomeEditor";
import { HOME, disconnect, savedDraft, updateComposer, useAgents } from "./store";
import { HOME, savedDraft, updateComposer, useAgents } from "./store";
const backend = vi.hoisted(() => ({ providers: [] as AgentProvider[], create: vi.fn() }));
const ok = <T,>(data: T) => Promise.resolve({ status: "ok", data });
@@ -11,16 +11,6 @@ vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
commands: {
agentProviders: () => ok(backend.providers),
agentRefreshModels: (provider: string) =>
ok({
provider,
config: backend.providers.find((p) => p.id === provider)?.config ?? [],
thoughtLevels: backend.providers.find((p) => p.id === provider)?.thoughtLevels ?? {},
refreshing: false,
refreshedAtMs: Date.now(),
retryAtMs: null,
error: null,
}),
agentProbe: () => ok(null),
vcsBranchInfo: () => ok({ head: "main" }),
vcsBranches: () => ok([]),
@@ -44,7 +34,6 @@ const option = (id: string, category: string, values: string[], current: string)
});
beforeEach(() => {
disconnect();
backend.create.mockReset().mockImplementation((req: NewAgentTask) => ok({ id: "new", config: req.config }));
backend.providers = [
{
@@ -106,7 +95,7 @@ it("switches model-specific levels and restores each draft effort before startin
it.each(["plain", "unknown"])("hides effort for %s models and sends no stale effort", async (model) => {
render(<HomeEditor />);
await chooseModel(model);
expect(screen.getByRole("button", { name: "Model" })).toHaveAttribute("aria-haspopup", "dialog");
expect(screen.getByRole("button", { name: "Model" })).toHaveAttribute("aria-haspopup", "listbox");
fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(backend.create).toHaveBeenCalled());
expect(backend.create.mock.calls[0]![0].config).toEqual([{ id: "model", value: model }]);
+11 -46
Переглянути файл
@@ -20,7 +20,6 @@ import {
NO_COMMANDS,
draftConfig,
refreshProviders,
refreshModels,
setDraftOption,
startTask,
updateDraft,
@@ -82,24 +81,6 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
const provider = providers.find((p) => p.id === draft.provider) ?? providers[0];
const probe = useProbe(provider?.installed ? provider.id : null, provider?.command);
const catalog = useAgents((s) => s.modelCatalogs.get(provider?.id ?? ""));
const requestingModels = useAgents((s) => s.modelRequests.has(provider?.id ?? ""));
const launch = provider && JSON.stringify([provider.program, provider.args, provider.env, provider.command]);
useEffect(() => {
if (!provider?.installed) return;
const refresh = () => void refreshModels(provider.id, false);
refresh();
window.addEventListener("focus", refresh);
return () => window.removeEventListener("focus", refresh);
}, [provider?.id, provider?.installed, launch, project?.root]);
const defaults = useAgentSettings().defaults;
const config = draftConfig(provider, draft, (provider && defaults[provider.id]) || {});
const unavailableModel = config.some(
(o) =>
o.category === "model" &&
draft.config[o.id] !== undefined &&
!o.values.some((v) => v.value === draft.config[o.id]),
);
const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS);
const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon);
// Without git under the project there are no worktrees: force the local checkout.
@@ -107,11 +88,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
const isolation = isGit ? draft.isolation : "local";
const base = draft.base ?? current;
const canStart =
canSend(composer) &&
!!provider?.installed &&
!unavailableModel &&
!starting &&
(!project?.workspace || repoState?.root === root);
canSend(composer) && !!provider?.installed && !starting && (!project?.workspace || repoState?.root === root);
const start = () => {
if (!canStart) return;
@@ -121,6 +98,8 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setStarting(false));
};
const defaults = useAgentSettings().defaults;
const config = draftConfig(provider, draft, (provider && defaults[provider.id]) || {});
return (
<div className={clsx(styles.home, docked && styles.docked)}>
@@ -230,21 +209,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
id === ADD_AGENT ? void executeAction("agents.addAgent") : updateDraft({ provider: id, config: {} })
}
/>
<ConfigPickers
key={provider?.id}
config={config}
onChange={setDraftOption}
modelRefresh={
provider?.installed
? {
refreshing: requestingModels || (catalog?.refreshing ?? false),
retryAtMs: catalog?.retryAtMs ?? null,
error: catalog?.error ?? null,
onRefresh: () => void executeAction("agents.refreshModels"),
}
: undefined
}
/>
<ConfigPickers config={config} onChange={setDraftOption} />
</>
}
actions={
@@ -258,11 +223,6 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
/>
{/* Notes under the box don't take part in centring, so the box stays put as they come and go. */}
<div className={styles.notes}>
{unavailableModel && (
<p className={styles.warning} role="alert">
<T>Choose another model before starting.</T>
</p>
)}
{probe && (!docked || probe.error) && (
<p className={probe.error ? styles.warning : styles.note}>
{probe.error
@@ -270,12 +230,17 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
: [probe.name, probe.version, probe.protocol].filter(Boolean).join(" · ")}
</p>
)}
{(error || (provider && !provider.installed)) && (
{(error || (provider && !provider.installed) || (provider && !provider.config.length)) && (
<p
className={error || !provider?.installed ? styles.warning : styles.note}
role={error ? "alert" : undefined}
>
{error ?? txTemplate(`${provider?.command ?? ""} was not found on PATH`)}
{error ??
(provider && !provider.installed ? (
txTemplate(`${provider.command} was not found on PATH`)
) : (
<T>Models and modes appear after this agent's first task.</T>
))}
</p>
)}
</div>
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff. Show more