Compare commits

..
Author SHA1 Message Date
l0ng-ai 0ed0ae4d06 fix(run): run JUnit configurations with the build's JDK 2026-11-11 09:40:07 +08:00
129 changed files with 301 additions and 2780 deletions

No files matched your search

Generated
+39 -39
View File
@@ -4145,7 +4145,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4169,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4183,7 +4183,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4194,7 +4194,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"chardetng",
@@ -4212,7 +4212,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4226,7 +4226,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4236,7 +4236,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4253,7 +4253,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4267,7 +4267,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4288,7 +4288,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"bb8",
"blake3",
@@ -4318,7 +4318,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4352,7 +4352,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4364,7 +4364,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4377,7 +4377,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4443,7 +4443,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4462,7 +4462,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4484,7 +4484,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"encoding_rs",
"futures",
@@ -4501,7 +4501,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4557,7 +4557,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"futures",
@@ -4583,7 +4583,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"bytes",
"dunce",
@@ -4602,7 +4602,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4625,7 +4625,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -4648,7 +4648,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4661,7 +4661,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"globset",
@@ -4680,7 +4680,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4704,7 +4704,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"ide-core",
@@ -4724,7 +4724,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"criterion",
"globset",
@@ -4744,7 +4744,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"parking_lot",
@@ -4757,7 +4757,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4778,7 +4778,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-language",
@@ -4793,7 +4793,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"libc",
@@ -4806,7 +4806,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"criterion",
"ide-core",
@@ -4819,7 +4819,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"flate2",
"hex",
@@ -4840,7 +4840,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -4861,7 +4861,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"chardetng",
"dunce",
@@ -4885,7 +4885,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"open",
@@ -4901,7 +4901,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.41"
version = "0.1.40"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
-11
View File
@@ -178,17 +178,6 @@ impl Negotiated {
pub fn meta(&self, pointer: &str) -> Option<&Value> {
self.capabilities.get("_meta")?.pointer(pointer)
}
/// First authentication method the agent can run over ACP (`type` absent or `"agent"`).
pub fn agent_auth_method(&self) -> Option<&str> {
self.auth_methods.iter().find_map(|m| {
let kind = m.get("type").and_then(Value::as_str).unwrap_or("agent");
(kind == "agent" && m.pointer("/_meta/terminal-auth").is_none())
.then(|| m.get("id").and_then(Value::as_str))
.flatten()
.filter(|id| !id.is_empty())
})
}
}
/// The client side of one agent connection: wraps the task's [`Handler`] and, once
-30
View File
@@ -135,36 +135,6 @@ fn the_shape_decides_the_version_not_the_number() {
assert_eq!((n.generation, n.load_session(), n.session("fork")), (2, false, false));
}
#[test]
fn picks_non_terminal_agent_auth_method() {
let v1 = Negotiated::from_initialize(&json!({
"protocolVersion": 1,
"authMethods": [
{ "id": "cli_login", "_meta": { "terminal-auth": { "command": "copilot", "args": ["login"] } } },
{ "id": "terminal_login", "type": "terminal", "args": ["login"] },
{ "id": "api_key", "type": "env_var" },
{ "id": "github_oauth", "name": "GitHub OAuth" }
]
}));
assert_eq!(v1.agent_auth_method(), Some("github_oauth"));
let v2 = Negotiated::from_initialize(&json!({
"protocolVersion": 2,
"info": { "name": "x", "version": "1" },
"authMethods": [
{ "type": "terminal", "methodId": "cli", "name": "CLI", "args": ["login"] },
{ "type": "agent", "methodId": "oauth", "name": "OAuth" }
]
}));
assert_eq!(v2.agent_auth_method(), Some("oauth"));
let terminal_only = Negotiated::from_initialize(&json!({
"protocolVersion": 1,
"authMethods": [{ "id": "cli", "type": "terminal", "args": ["login"] }]
}));
assert_eq!(terminal_only.agent_auth_method(), None);
}
#[test]
fn meta_and_auth_methods_pass_through() {
// The native shims advertise their extensions in `agentCapabilities._meta`.
-270
View File
@@ -1,270 +0,0 @@
//! Sessions the user started outside OpenIDEA (a terminal, another editor, the CLI):
//! scanned so they can be listed and imported as tasks instead of re-explained (#312).
//!
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
use super::native::claude::history;
use serde::Serialize;
use specta::Type;
use std::path::{Path, PathBuf};
/// A session on this machine that no task holds yet.
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSession {
/// The [`crate::AgentProvider`] id that owns it (`claude`, `codex`).
pub provider: String,
/// The id resuming it names (`--resume`, `thread/resume`).
pub session_id: String,
pub title: String,
/// Where it ran, when the transcript names one.
pub cwd: Option<String>,
/// File modification time, ms since the epoch.
pub updated_ms: f64,
}
/// Modification time of `path` in ms, else 0.
fn mtime(path: &Path) -> f64 {
path.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as f64)
.unwrap_or(0.0)
}
/// Short display text: collapsed whitespace, truncated to `MAX_TITLE` chars.
fn short(text: &str) -> String {
const MAX_TITLE: usize = 80;
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= MAX_TITLE {
flat
} else {
format!("{}…", flat.chars().take(MAX_TITLE - 1).collect::<String>())
}
}
/// The text of a Claude Code transcript entry's message, when it carries prose.
fn message_text(e: &serde_json::Value) -> Option<String> {
match e.pointer("/message/content") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(serde_json::Value::Array(blocks)) => {
let text: Vec<&str> = blocks
.iter()
.filter(|b| b.get("type").and_then(serde_json::Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(serde_json::Value::as_str))
.collect();
(!text.is_empty()).then(|| text.join("\n"))
}
_ => None,
}
}
/// Claude Code transcripts: `<config>/projects/*/<id>.jsonl`.
pub fn claude_sessions(config_dir: &Path) -> Vec<ExternalSession> {
let mut out = Vec::new();
let projects = config_dir.join("projects");
let dirs: Vec<PathBuf> = std::fs::read_dir(&projects).into_iter().flatten().flatten().map(|d| d.path()).collect();
for dir in dirs {
let files: Vec<PathBuf> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|d| d.path())
.filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
.collect();
for path in files {
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_owned();
if id.is_empty() {
continue;
}
let entries = history::read(&path).unwrap_or_default();
if entries.is_empty() {
continue;
}
let title = history::title(&entries)
.or_else(|| entries.iter().filter(|e| history::is_turn_start(e)).filter_map(message_text).next())
.map(|t| short(&t))
.unwrap_or_else(|| format!("Claude Code session {}", &id[..id.len().min(8)]));
let cwd = entries
.iter()
.filter_map(|e| e.get("cwd").and_then(serde_json::Value::as_str))
.next()
.map(str::to_owned);
out.push(ExternalSession {
provider: "claude".to_owned(),
session_id: id,
title,
cwd,
updated_ms: mtime(&path),
});
}
}
out
}
/// The `session_meta` line of a Codex rollout file: `(id, cwd)`, across shapes.
fn codex_meta(line: &serde_json::Value) -> Option<(String, Option<String>)> {
let payload =
line.get("payload").filter(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("session_meta"));
let inner = payload.and_then(|p| p.get("payload")).or_else(|| {
line.get("type").and_then(serde_json::Value::as_str).filter(|t| *t == "session_meta").and(Some(line))
})?;
let id = inner.get("id").and_then(serde_json::Value::as_str)?.to_owned();
let cwd = inner.get("cwd").and_then(serde_json::Value::as_str).map(str::to_owned);
Some((id, cwd))
}
/// A prose line of a Codex rollout file (a user message), across shapes.
fn codex_user_text(line: &serde_json::Value) -> Option<String> {
let payload = line.get("payload")?;
let ty = payload.get("type").and_then(serde_json::Value::as_str)?;
if !(ty == "user_message" || ty == "userMessage" || ty == "event_msg") {
return None;
}
let inner = payload.get("payload").unwrap_or(&serde_json::Value::Null);
for v in [payload, inner] {
if let Some(s) = v.get("message").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
if let Some(s) = v.get("text").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
}
None
}
/// One rollout file's session, or `None` when it names no `session_meta`.
fn codex_file(path: &Path) -> Option<ExternalSession> {
let text = std::fs::read_to_string(path).ok()?;
let mut id: Option<(String, Option<String>)> = None;
let mut title: Option<String> = None;
// `session_meta` is first, but cap the scan: giant rollouts must not stall listing.
for line in text.lines().take(400) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
if id.is_none()
&& let Some(meta) = codex_meta(&v)
{
id = Some(meta);
}
if title.is_none()
&& let Some(t) = codex_user_text(&v)
{
title = Some(short(&t));
}
if id.is_some() && title.is_some() {
break;
}
}
let (session_id, cwd) = id?;
Some(ExternalSession {
provider: "codex".to_owned(),
title: title.unwrap_or_else(|| format!("Codex session {}", &session_id[..session_id.len().min(8)])),
session_id,
cwd,
updated_ms: mtime(path),
})
}
/// Collects `*.jsonl` below `dir`, up to 4 levels (`sessions/YYYY/MM/DD/<rollout>`).
fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 {
return;
}
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
rollout_files(&path, depth - 1, out);
} else if path.extension().is_some_and(|e| e == "jsonl") {
out.push(path);
}
}
}
/// Codex sessions: `<home>/.codex/sessions/**`.
pub fn codex_sessions(codex_home: &Path) -> Vec<ExternalSession> {
let mut files = Vec::new();
rollout_files(&codex_home.join("sessions"), 4, &mut files);
files.iter().filter_map(|p| codex_file(p)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn write(path: &Path, lines: &[serde_json::Value]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut f = std::fs::File::create(path).unwrap();
for line in lines {
writeln!(f, "{line}").unwrap();
}
}
fn claude_user(uuid: &str, text: &str) -> serde_json::Value {
json!({"type": "user", "uuid": uuid, "parentUuid": null,
"message": {"role": "user", "content": text}, "cwd": "/work/app"})
}
#[test]
fn lists_claude_transcripts_with_titles() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("projects/-work-app/abc123.jsonl"),
&[
claude_user("u1", "fix the login bug"),
json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1",
"message": {"role": "assistant", "content": []}}),
json!({"type": "ai-title", "aiTitle": "Login fix"}),
],
);
write(&tmp.path().join("projects/-work-app/empty.jsonl"), &[]);
let sessions = claude_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("claude", "abc123"));
assert_eq!(s.title, "Login fix");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
assert!(s.updated_ms > 0.0);
}
#[test]
fn claude_title_falls_back_to_the_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
write(&tmp.path().join("projects/-x/def456.jsonl"), &[claude_user("u1", "hello there")]);
let sessions = claude_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].title, "hello there");
}
#[test]
fn lists_codex_rollouts_with_meta() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("sessions/2026/01/02/rollout-01.jsonl"),
&[
json!({"timestamp": "t", "payload": {"type": "session_meta",
"payload": {"id": "thread-1", "cwd": "/work/app"}}}),
json!({"timestamp": "t", "payload": {"type": "user_message",
"payload": {"message": "add tests"}}}),
],
);
write(&tmp.path().join("sessions/notes.jsonl"), &[json!({"nope": true})]);
let sessions = codex_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
assert_eq!(s.title, "add tests");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
}
#[test]
fn missing_directories_list_nothing() {
let tmp = tempfile::tempdir().unwrap();
assert!(claude_sessions(&tmp.path().join("nope")).is_empty());
assert!(codex_sessions(&tmp.path().join("nope")).is_empty());
}
}
+5 -122
View File
@@ -10,7 +10,6 @@
mod acp;
mod config;
mod conversation;
mod external;
pub mod mcp;
mod model;
mod native;
@@ -20,7 +19,6 @@ mod skills;
mod timeline;
mod worktree;
pub use external::ExternalSession;
pub use model::*;
use conversation::{Conversation, SavedSubagent, Session};
@@ -587,91 +585,6 @@ impl AgentManager {
Ok(timeline.entries.clone())
}
/// Session ids the tasks already hold (native drivers reuse the CLI's own id).
fn imported_session_ids(&self) -> HashSet<String> {
self.tasks.read().values().filter_map(|t| t.state.lock().session_id.clone()).collect()
}
/// Sessions on this machine that no task holds yet, newest first (#312).
pub fn external_sessions(&self) -> Vec<ExternalSession> {
let mut out = Vec::new();
if let Some(spec) = self.shared.spec("claude")
&& let Some(dir) = native::claude::cli::config_dir(&spec.env)
{
out.extend(external::claude_sessions(&dir));
}
let home = self.shared.paths.home_dir.clone().or_else(home_dir);
if let Some(home) = home {
out.extend(external::codex_sessions(&home.join(".codex")));
}
let known = self.imported_session_ids();
out.retain(|s| !known.contains(&s.session_id));
out.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
out.truncate(50);
out
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
pub fn import_session(&self, provider: &str, session_id: &str) -> IdeResult<AgentTask> {
if !matches!(provider, "claude" | "codex") {
return Err(IdeError::invalid(format!("cannot import {provider} sessions")));
}
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
if self.imported_session_ids().contains(session_id) {
return Err(IdeError::invalid("that session is already a task"));
}
let found = self
.external_sessions()
.into_iter()
.find(|s| s.provider == provider && s.session_id == session_id)
.ok_or_else(|| IdeError::not_found(format!("unknown {provider} session {session_id}")))?;
let root = self.shared.paths.project_root.clone();
let cwd = found.cwd.map(PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| root.clone());
let config = self.shared.known_config(&spec.id);
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
let task = AgentTask {
project_root: Some(IpcPath(root)),
id,
title: found.title,
provider: spec.id.clone(),
model: model_of(&config),
status: AgentStatus::Ready,
isolation: Isolation::Local,
cwd: IpcPath(cwd),
branch: None,
base: None,
base_commit: None,
error: None,
created_ms: now,
updated_ms: now,
config,
commands: self.shared.known_commands(&spec.id),
usage: None,
queue: Vec::new(),
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
background: Box::default(),
};
let file = TaskFile {
task: task.clone(),
session_id: Some(session_id.to_owned()),
entries: Vec::new(),
wanted: Vec::new(),
before: BTreeMap::new(),
saved: None,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: true,
};
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
Ok(task)
}
/// Creates the task (and its worktree) and sends `req.prompt` as its first message.
pub async fn create(&self, req: NewAgentTask) -> IdeResult<AgentTask> {
let spec = self
@@ -1710,30 +1623,6 @@ impl Task {
})
}
/// Runs `method` and, on an auth error, tries the agent's own ACP `authenticate` once before retrying.
async fn request_with_auth(
&self,
peer: &acp::Conn,
init: &acp::Negotiated,
tried_auth: &mut bool,
method: &str,
params: Value,
) -> Result<Value, RpcError> {
let first = peer.request(method, params.clone(), None).await;
if !*tried_auth
&& let Err(RpcError::Server { code, ref message, .. }) = first
&& acp::auth_required(code, message)
&& let Some(method_id) = init.agent_auth_method()
{
*tried_auth = true;
match peer.request::<_, Value>("authenticate", json!({ "methodId": method_id }), None).await {
Ok(_) => return peer.request(method, params, None).await,
Err(e) => tracing::debug!(task = %self.id, method_id, error = %e, "agent authenticate failed"),
}
}
first
}
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
let _guard = self.connecting.lock().await;
if let Some(live) = self.live.lock().clone()
@@ -1776,12 +1665,12 @@ impl Task {
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
let mut tried_auth = false;
let mut session = None;
if let (Some(id), true) = (saved, can_load) {
self.replaying.store(true, Ordering::Release);
let params = json!({ "sessionId": &id, "cwd": &cwd_s, "mcpServers": &mcp_servers });
let loaded = self.request_with_auth(&peer, &init, &mut tried_auth, "session/load", params).await;
let loaded: Result<Value, RpcError> = peer
.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers }), None)
.await;
tokio::time::sleep(REPLAY_GRACE).await;
self.replaying.store(false, Ordering::Release);
match loaded {
@@ -1793,9 +1682,8 @@ impl Task {
let (session_id, result) = match session {
Some(s) => s,
None => {
let params = json!({ "cwd": &cwd_s, "mcpServers": &mcp_servers });
let v = self
.request_with_auth(&peer, &init, &mut tried_auth, "session/new", params)
let v: Value = peer
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": mcp_servers }), None)
.await
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
@@ -2541,11 +2429,6 @@ fn now_ms() -> f64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
}
/// The user's home, for tools that keep state there (Codex sessions).
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
}
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
+1 -1
View File
@@ -135,7 +135,7 @@ impl Cli for Process {
}
/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`).
pub(crate) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
pub(super) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
let set = env
.iter()
.find(|(k, _)| k == "CLAUDE_CONFIG_DIR")
@@ -35,7 +35,7 @@ pub(super) fn escape(cwd: &Path) -> String {
cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
}
pub(crate) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
pub(super) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
let text = std::fs::read_to_string(path)?;
Ok(text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
}
@@ -111,7 +111,7 @@ fn block_ids<'a>(e: &'a Value, ty: &'a str, key: &'a str) -> impl Iterator<Item
/// Whether `e` is a message that started a turn: the user's own, not a tool result, a
/// steer, or text Claude Code added.
pub(crate) fn is_turn_start(e: &Value) -> bool {
pub(super) fn is_turn_start(e: &Value) -> bool {
if kind(e) != "user" || e.get("isMeta").and_then(Value::as_bool) == Some(true) {
return false;
}
@@ -149,7 +149,7 @@ pub(super) fn cut(chain: &[&Value], turns: usize) -> Result<Option<String>, Stri
}
/// The newest title Claude Code gave the session.
pub(crate) fn title(entries: &[Value]) -> Option<String> {
pub(super) fn title(entries: &[Value]) -> Option<String> {
entries
.iter()
.rev()
+2 -2
View File
@@ -7,8 +7,8 @@
//! <id>.json`): loading that session, from this connection or a fresh one, starts it that
//! way.
pub(crate) mod cli;
pub(crate) mod history;
mod cli;
mod history;
mod settings;
mod tools;
mod translate;
+3 -101
View File
@@ -35,9 +35,6 @@ struct FakeAgent {
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
auth_on_load: bool,
authenticate_method: Option<String>,
authenticated: AtomicBool,
request_error: Option<(i64, String)>,
auth_methods: Vec<Value>,
/// Hold the late mode reply until the prompt's permission request is already waiting.
@@ -130,9 +127,9 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
let needs_auth = !self.authenticated.load(Ordering::Acquire)
&& ((method == "session/new" && self.auth_on_new) || (method == "session/load" && self.auth_on_load));
if needs_auth || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) {
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err(self
.request_error
.clone()
@@ -179,16 +176,6 @@ impl Handler for FakeAgent {
self.say(&session, "replayed history");
Ok(json!({}))
}
"authenticate" if self.authenticate_method.is_some() => {
let id = params.get("methodId").and_then(Value::as_str).unwrap_or_default();
self.calls.lock().push(format!("authenticate {id}"));
if Some(id) == self.authenticate_method.as_deref() {
self.authenticated.store(true, Ordering::Release);
Ok(json!({}))
} else {
Err((codes::INVALID_PARAMS, format!("unexpected methodId {id}")))
}
}
"session/set_config_option" | "session/set_mode" => {
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
@@ -709,91 +696,6 @@ async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn authenticates_and_retries_session_setup_once_on_auth_error() {
let tmp = tempfile::tempdir().unwrap();
let methods = vec![
json!({"id": "cli_login", "_meta": {"terminal-auth": {"command": "copilot", "args": ["login"]}}}),
json!({"id": "github_oauth", "name": "GitHub OAuth"}),
];
let agent = Arc::new(FakeAgent {
auth_on_new: true,
authenticate_method: Some("github_oauth".into()),
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods.clone(),
..Default::default()
});
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "copilot".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(
agent
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/new", "authenticate", "authenticate github_oauth", "session/new", "session/prompt"],
);
// Resuming after an IDE restart also authenticates once and retries `session/load`.
mgr.shutdown();
let agent2 = Arc::new(FakeAgent {
auth_on_load: true,
auth_on_new: true,
authenticate_method: Some("github_oauth".into()),
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods.clone(),
..Default::default()
});
let mgr2 = manager(tmp.path(), agent2.clone());
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
assert_eq!(
agent2
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/load", "authenticate", "authenticate github_oauth", "session/load", "session/prompt"],
);
// If `authenticate` fails during `session/load`, fallback to `session/new` does not try it again.
mgr2.shutdown();
let agent3 = Arc::new(FakeAgent {
auth_on_load: true,
auth_on_new: true,
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods,
..Default::default()
});
let mgr3 = manager(tmp.path(), agent3.clone());
mgr3.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr3, &task.id, AgentStatus::Failed).await;
assert_eq!(
agent3
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/load", "authenticate", "session/new"],
);
mgr3.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
-2
View File
@@ -322,7 +322,6 @@ pub struct TerminalSettings {
pub override_ide_shortcuts: bool,
pub shell_integration: bool,
pub highlight_hyperlinks: bool,
pub highlight_selection: bool,
pub option_as_meta: bool,
/// Escape moves focus to the editor instead of reaching the shell.
pub escape_to_editor: bool,
@@ -355,7 +354,6 @@ impl Default for TerminalSettings {
override_ide_shortcuts: true,
shell_integration: true,
highlight_hyperlinks: true,
highlight_selection: true,
option_as_meta: false,
escape_to_editor: false,
cursor_shape: "block".into(),
+1 -16
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -181,17 +181,6 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
host.project(window)?.agents.entries(&task, subagent)
}
/// Sessions on this machine that no task holds yet, newest first (#312).
pub fn external_sessions(host: &Host, window: &str) -> IdeResult<Vec<ExternalSession>> {
Ok(host.project(window)?.agents.external_sessions())
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
pub fn import_session(host: &Host, window: &str, provider: String, session_id: String) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.import_session(&provider, &session_id)
}
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
host.require_trust(window)?;
let project = host.project(window)?;
@@ -409,10 +398,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
d.add("agent_external_sessions", |c| async move { external_sessions(&c.host, &c.window) });
d.add("agent_import_session", |c| async move {
import_session(&c.host, &c.window, c.arg("provider")?, c.arg("sessionId")?)
});
d.add("agent_prompt", |c| async move {
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
});
-12
View File
@@ -130,17 +130,6 @@ pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap
ide_run::system_env()
}
/// Variables of the `.env` files a debug launch names, in order (later files win):
/// the same files a run loads, over the system environment debug adapters inherit.
pub fn run_env_files(
_host: &Host,
_window: &str,
files: Vec<std::path::PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
ide_run::envfile::load(&files, &ide_run::system_env())
.map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
}
/// Writes the Gradle init script that adds `jvm_args` to the JVMs the build forks
/// (`bootRun`, `run`, tests) and returns its path, for `--init-script`.
pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: Vec<String>) -> IdeResult<String> {
@@ -343,7 +332,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
run_import_configs(&c.host, &c.window, c.arg("source")?, c.arg("existing")?)
});
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
d.add("run_env_files", |c| async move { run_env_files(&c.host, &c.window, c.arg("files")?) });
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
+2 -6
View File
@@ -27,9 +27,6 @@ struct Harness {
}
impl Harness {
fn new() -> Self {
Self::with_timeout(Duration::from_millis(250))
}
fn with_timeout(timeout: Duration) -> Self {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
@@ -59,7 +56,7 @@ impl Harness {
PluginHost::new(
tmp.path().join("plugins"),
Arc::new(move |w: Option<&str>, e| out.lock().unwrap().push((w.unwrap_or_default().to_owned(), e))),
Limits { timeout, ..Default::default() },
Limits { timeout: Duration::from_millis(250), ..Default::default() },
)
.unwrap(),
);
@@ -205,8 +202,7 @@ async fn database_imports_are_denied_without_the_optional_grant() {
#[tokio::test(flavor = "multi_thread")]
async fn official_api_examples_install_and_render_real_component_contributions() {
// The real examples' first calls compile and scan; 250 ms is too tight on Windows runners.
let f = Harness::with_timeout(Duration::from_secs(10));
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let official = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official");
for name in ["blame-annotations", "todo-finder"] {
-19
View File
@@ -157,22 +157,3 @@ async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
assert!(escaped.is_err(), "{escaped:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn debug_env_files_load_in_order_like_a_run() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let first = tmp.path().join("first.env");
let second = tmp.path().join("second.env");
std::fs::write(&first, "SHARED=one\nONLY_FIRST=1\nREF=${SHARED}-suffix\n").unwrap();
std::fs::write(&second, "SHARED=two\nONLY_SECOND=2\n").unwrap();
let vars = ops::run::run_env_files(&host, "main", vec![first, second]).unwrap();
// Later files win; references resolve against earlier files, then the system environment.
assert_eq!(vars.get("SHARED").map(String::as_str), Some("two"));
assert_eq!(vars.get("ONLY_FIRST").map(String::as_str), Some("1"));
assert_eq!(vars.get("ONLY_SECOND").map(String::as_str), Some("2"));
assert_eq!(vars.get("REF").map(String::as_str), Some("one-suffix"));
// An unreadable file is an error, like a run that names a missing `.env`.
let missing = ops::run::run_env_files(&host, "main", vec![tmp.path().join("nope.env")]);
assert!(missing.is_err(), "{missing:?}");
}
+2 -93
View File
@@ -11,29 +11,10 @@ use ide_jsonrpc::{Handler, codes};
use lsp_types as lsp;
use serde_json::{Value, json};
use std::sync::{Arc, Weak};
use std::time::Duration;
/// Progress token standing for `angular/projectLoadingStart` … `Finish`.
const ANGULAR_LOADING: &str = "angular/projectLoading";
/// How often a running progress token's silence is checked.
const PROGRESS_SWEEP: Duration = Duration::from_secs(15);
/// A token that reports nothing this long is marked stuck: jdtls's `Importing Maven
/// project(s)` waits minutes on one repository that never answers (`DefaultMetadataResolver`
/// awaits its whole batch), and a task frozen at one percent is not progress. It stays
/// visible, but stops holding the server busy while the work behind it keeps running.
const PROGRESS_STALE: Duration = Duration::from_secs(180);
/// Task message after [`PROGRESS_STALE`] of silence: keep the server's last message and
/// say how long nothing happened.
fn stalled_message(message: Option<&str>) -> String {
let secs = PROGRESS_STALE.as_secs();
match message.filter(|m| !m.is_empty()) {
Some(message) => format!("{message} — no progress for {secs} s"),
None => format!("No progress for {secs} s"),
}
}
pub struct ClientHandler {
mgr: Weak<LspManager>,
server: Arc<Server>,
@@ -67,14 +48,13 @@ impl ClientHandler {
other => other.to_string(),
};
let value = &params["value"];
let kind = value.get("kind").and_then(Value::as_str);
let message = value.get("message").and_then(Value::as_str).map(str::to_owned);
let fraction = value.get("percentage").and_then(Value::as_f64).map(|p| (p / 100.0).clamp(0.0, 1.0) as f32);
let cancellable = value.get("cancellable").and_then(Value::as_bool);
let mut replaced = None;
let event = {
let mut progress = self.server.progress.lock();
match kind {
match value.get("kind").and_then(Value::as_str) {
Some("begin") => {
let title = value
.get("title")
@@ -89,18 +69,7 @@ impl ClientHandler {
// (PHPantom: begin, begin, end, end). The task it replaces ends now, or no
// `end` would ever reach it.
replaced = progress
.insert(
token.clone(),
Progress {
task,
title: title.clone(),
message: message.clone(),
cancel,
last: tokio::time::Instant::now(),
stalled: false,
fraction,
},
)
.insert(token, Progress { task, title: title.clone(), message: message.clone(), cancel })
.map(|p| IdeEvent::Task {
id: p.task,
title: p.title,
@@ -113,14 +82,9 @@ impl ClientHandler {
}
Some("report") => {
let Some(p) = progress.get_mut(&token) else { return };
p.last = tokio::time::Instant::now();
p.stalled = false;
if message.is_some() {
p.message = message;
}
if fraction.is_some() {
p.fraction = fraction;
}
match cancellable {
Some(true) if p.cancel.is_none() => {
p.cancel = Some(self.cancel_handle(p.task, &params["token"]))
@@ -159,64 +123,9 @@ impl ClientHandler {
self.server.refresh_busy_status(mgr.sink());
if ended {
self.server.progress_ended();
} else if kind == Some("begin") {
self.watch_progress(mgr, token);
}
}
/// A task that reports nothing for [`PROGRESS_STALE`] is stuck: the work behind it is
/// blocked on something that never answers, and a bar frozen at one percent is not
/// progress. Keep its task visible but mark it, and stop counting it as busy: a slow
/// import can resume and finish in the same task, while the IDE stays usable.
fn watch_progress(&self, mgr: &LspManager, token: String) {
let (weak, server, generation) = (self.mgr.clone(), self.server.clone(), self.generation);
// The notification runs on a reader thread, not inside a runtime.
mgr.runtime.spawn(async move {
loop {
tokio::time::sleep(PROGRESS_SWEEP).await;
let Some(mgr) = weak.upgrade() else { return };
if server.running_gen(generation).is_none() || !server.has_progress(&token) {
return; // the instance is gone, or the task ended normally
}
if server.progress_silent(&token, PROGRESS_STALE) {
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), generation);
if handler.mark_progress_stalled(&mgr, &token) {
tracing::warn!(
"{}: progress '{token}' reported nothing for {} s; marking it stuck",
server.name,
PROGRESS_STALE.as_secs()
);
}
}
}
});
}
/// Marks a silent task as possibly stuck without ending it. Returns whether it was
/// newly marked; a later `report` clears the mark and makes the server busy again.
fn mark_progress_stalled(&self, mgr: &LspManager, token: &str) -> bool {
let event = {
let mut progress = self.server.progress.lock();
let Some(p) = progress.get_mut(token) else { return false };
if p.stalled || !p.silent(PROGRESS_STALE) {
return false;
}
p.stalled = true;
IdeEvent::Task {
id: p.task,
title: p.title.clone(),
message: Some(stalled_message(p.message.as_deref())),
fraction: p.fraction,
done: false,
cancellable: p.cancel.is_some(),
}
};
self.server.emit(mgr.sink(), event);
self.server.refresh_busy_status(mgr.sink());
self.server.progress_ended();
true
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
+3 -81
View File
@@ -74,22 +74,6 @@ pub struct Progress {
pub message: Option<String>,
/// Set while the server accepts `window/workDoneProgress/cancel` for it.
pub cancel: Option<ide_core::task::CancelHandle>,
/// When the server last reported on this token; a task that stops reporting is stuck.
pub last: tokio::time::Instant,
/// Last percentage, so a stalled task does not lose its bar.
pub fraction: Option<f32>,
/// Marked after the stale window of silence. The task stays visible, but no longer
/// counts as busy: the work may resume and finish in the same task.
pub stalled: bool,
}
impl Progress {
/// Nothing came for `stale` and the task is not already marked: the work behind it is
/// blocked on something that never answers (an HTTP read with no response header, Maven
/// waiting for a repository). Its task must not hold the IDE while that lasts.
pub fn silent(&self, stale: Duration) -> bool {
!self.stalled && tokio::time::Instant::now() >= self.last + stale
}
}
pub struct Server {
@@ -558,10 +542,9 @@ impl Server {
self.publish(sink);
}
/// Whether the server reports work in progress (indexing, analysis). A task marked
/// stuck stays visible but no longer holds the server busy.
/// Whether the server reports work in progress (indexing, analysis).
pub fn is_busy(&self) -> bool {
self.progress.lock().values().any(|p| !p.stalled) || *self.loading.lock() == Some(true)
!self.progress.lock().is_empty() || *self.loading.lock() == Some(true)
}
/// Whether the server says it is still loading the project, so an empty
@@ -581,16 +564,6 @@ impl Server {
self.phase_ended();
}
/// Whether progress token `token` is still running on this instance.
pub fn has_progress(&self, token: &str) -> bool {
self.progress.lock().contains_key(token)
}
/// Whether token `token` has reported nothing for `stale`.
pub fn progress_silent(&self, token: &str, stale: Duration) -> bool {
self.progress.lock().get(token).is_some_and(|p| p.silent(stale))
}
/// Called after a `$/progress` task ended.
pub fn progress_ended(&self) {
self.phase_ended();
@@ -857,15 +830,7 @@ mod tests {
s.set_diagnostics(path.clone(), vec![lsp::Diagnostic::default()]);
s.progress.lock().insert(
"old".into(),
Progress {
task: TaskId::next(),
title: "Old work".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: None,
stalled: false,
},
Progress { task: TaskId::next(), title: "Old work".into(), message: None, cancel: None },
);
let seen = Arc::new(Mutex::new(Vec::new()));
let manager = Arc::new(Mutex::new(std::sync::Weak::<crate::LspManager>::new()));
@@ -904,49 +869,6 @@ mod tests {
Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue"))
}
#[tokio::test(start_paused = true)]
async fn a_progress_task_turns_silent_once_its_reports_stop() {
let stale = Duration::from_secs(180);
let mut p = Progress {
task: TaskId::next(),
title: "Importing Maven project(s)".into(),
message: Some("https://maven.example/".into()),
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.44),
stalled: false,
};
assert!(!p.silent(stale));
tokio::time::advance(stale).await;
assert!(p.silent(stale), "no report for the whole window");
// A report puts the task back to work, whatever it was doing.
p.last = tokio::time::Instant::now();
p.stalled = true;
assert!(!p.silent(stale));
p.stalled = false;
assert!(!p.silent(stale));
}
#[test]
fn a_stalled_progress_task_does_not_keep_the_server_busy() {
let s = server();
s.progress.lock().insert(
"p".into(),
Progress {
task: TaskId::next(),
title: "Importing Maven project(s)".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: None,
stalled: true,
},
);
assert!(!s.is_busy(), "a stuck task must not hold wait_idle or ReferencesIncomplete::Indexing");
s.progress.lock().get_mut("p").unwrap().stalled = false;
assert!(s.is_busy(), "a reporting task keeps the server busy");
}
#[test]
fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() {
let s = server();
-53
View File
@@ -1685,59 +1685,6 @@ async fn progress_tasks_and_busy_status() {
assert!(tasks[2].3);
}
#[tokio::test(start_paused = true)]
async fn a_progress_task_that_stops_reporting_is_marked_but_stays_open() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let peer = h.launcher.state("mock").peer();
peer.notify(
"$/progress",
json!({ "token": "mvn", "value": {
"kind": "begin",
"title": "Initialize Workspace",
"message": "Importing Maven project(s) - https://maven.example/",
"percentage": 44,
} }),
)
.unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
// The import is blocked on a repository that never answers: nothing reports again.
tokio::time::advance(Duration::from_secs(200)).await;
h.wait_status("mock", ServerStatus::Ready).await;
let marked = h.events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Task { title, message, fraction, done, .. } if title == "Initialize Workspace" && !done => {
Some((message.clone(), *fraction))
}
_ => None,
});
let (message, fraction) = marked.expect("the task stays open");
let message = message.expect("the last message is kept");
assert!(message.contains("no progress for 180 s"), "{message}");
assert_eq!(fraction, Some(0.44), "the bar keeps its last value");
assert!(
!h.events
.lock()
.iter()
.any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "Initialize Workspace")),
"a stalled task must not be closed"
);
// A late report brings it back to Busy and finishes in the same task.
peer.notify(
"$/progress",
json!({ "token": "mvn", "value": { "kind": "report", "message": "halfway", "percentage": 60 } }),
)
.unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
peer.notify("$/progress", json!({ "token": "mvn", "value": { "kind": "end" } })).unwrap();
h.wait_status("mock", ServerStatus::Ready).await;
assert!(
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { title, message: Some(m), done: true, .. } if title == "Initialize Workspace" && m == "halfway")),
"the same task ends with the final message"
);
}
/// While a server says it is loading the project (rust-analyzer: `serverStatus`
/// not quiescent) an empty or withdrawn answer is asked again once loading moves
/// on. `$/progress` alone (background indexing, `cargo check`) doesn't wait.
+5 -84
View File
@@ -1,14 +1,13 @@
//! Deployment over SFTP or local folders, and SSH run targets.
//! Shared by IPC commands and project save/delete hooks.
use crate::deploy::{Deployment, FileStamp, SyncEntry, SyncStatus, SyncStep, TransferReport, status};
use crate::mapping::{Excludes, Mappings, ResolvedMapping, posix};
use crate::target::{RemoteRuns, RemoteStart, SyncFn, rewrite_external_classpath};
use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport};
use crate::mapping::{Excludes, Mappings, ResolvedMapping};
use crate::target::{RemoteRuns, RemoteStart, SyncFn};
use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget};
use ide_core::settings::Settings;
use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink};
use ide_run::{OutputChunk, RunConfig, RunInfo};
use ide_ssh::sftp::{join, mtime_of};
use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -360,29 +359,10 @@ impl RemoteService {
return Err(e);
}
};
// #2641: the resolved `-cp` may reference jars outside the project root
// (`~/.m2`), which deployment sync never uploads. Mirror them under the
// deployment and rewrite the command onto the remote copies. Entries inside
// the project root are untouched: `target_command` maps those already.
let (args, libraries) = rewrite_external_classpath(&config.args, root, &remote_root);
let mut config = config.clone();
config.args = args;
for (local, _) in &libraries {
if !local.exists() {
fs.close().await;
return Err(IdeError::invalid(format!(
"cannot run on '{}': classpath entry {} does not exist on this machine; \
package the application (for example `mvn package`) or fix the launch classpath",
target.name,
local.display()
)));
}
}
let sync: Option<SyncFn> = if target.sync_before_run {
let deployment = settings.deployment.clone();
let root = root.to_path_buf();
let remote_root = remote_root.clone();
let preserve_time = settings.deployment.preserve_timestamps;
Some(Box::new(move |out| {
Box::pin(async move {
let server = DeploymentServer { root_path: remote_root.clone(), ..Default::default() };
@@ -397,7 +377,6 @@ impl RemoteService {
};
let mut progress = |_: &str, _: usize, _: usize| {};
let report = d.push_changes(&root, &mut progress).await;
let synced = sync_external_libraries(&fs, &libraries, preserve_time).await;
fs.close().await;
let report = report?;
out(OutputChunk::System {
@@ -408,17 +387,7 @@ impl RemoteService {
ide_core::error::ErrorKind::Io,
format!("upload of {} failed: {}", e.path, e.message),
)),
None => {
let synced = synced?;
if synced > 0 {
out(OutputChunk::System {
text: format!(
"Synced {synced} external classpath file(s) to {remote_root}/.remote-libs\n"
),
});
}
Ok(())
}
None => Ok(()),
}
})
}))
@@ -427,58 +396,10 @@ impl RemoteService {
None
};
let start = RemoteStart { session, target, root: root.to_path_buf(), remote_root, sync };
Ok(self.runs.start(start, &config, sink, on_output))
Ok(self.runs.start(start, config, sink, on_output))
}
}
/// Uploads outside-root classpath entries to their mirrored remote copies,
/// skipping files the remote already has (same size and time). Returns the
/// uploaded count. Missing locals are rejected in `run` before the sync starts.
async fn sync_external_libraries(
fs: &RemoteFs,
libraries: &[(PathBuf, String)],
preserve_time: bool,
) -> IdeResult<usize> {
fn fail(path: &str, e: IdeError) -> IdeError {
IdeError::new(ide_core::error::ErrorKind::Io, format!("sync of {path} failed: {}", e.message))
}
let mut synced = 0;
for (local, remote_base) in libraries {
let mut files = Vec::new();
if local.is_dir() {
let walk = ignore::WalkBuilder::new(local).standard_filters(false).follow_links(false).build();
for entry in walk.flatten() {
let path = entry.path();
// `is_file` (not the entry type): symlinked jars count.
if !path.is_file() {
continue;
}
let Ok(rel) = path.strip_prefix(local) else { continue };
files.push((path.to_path_buf(), join(remote_base, &posix(rel))));
}
} else {
files.push((local.clone(), remote_base.clone()));
}
for (file, remote) in files {
let size = std::fs::metadata(&file).map_err(|e| fail(&remote, e.into()))?.len();
let local_stamp = FileStamp { size, mtime: mtime_of(&file) };
let remote_stamp = match fs.stat(&remote).await.map_err(|e| fail(&remote, e))? {
Some(e) if e.is_dir => {
return Err(IdeError::invalid(format!("cannot sync onto the folder {remote}")));
}
Some(e) => Some(FileStamp { size: e.size, mtime: e.mtime }),
None => None,
};
if matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same) {
continue;
}
fs.upload(&file, &remote, preserve_time).await.map_err(|e| fail(&remote, e))?;
synced += 1;
}
}
Ok(synced)
}
fn sanitize(s: &str) -> String {
s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()
}
+1 -147
View File
@@ -10,7 +10,7 @@ use ide_ssh::{Output, RemoteProcess, Session, Sig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -118,105 +118,6 @@ pub fn argv_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_
target_command(config, target, root, remote_root, false)
}
/// `java` flags whose value is a path list that may point outside the project
/// (`~/.m2` jars, `--module-path` entries): deployment sync only uploads the
/// project root, so these entries need their own sync + rewrite (#2641).
const PATH_LIST_FLAGS: [&str; 6] =
["-cp", "-classpath", "--class-path", "-p", "--module-path", "--upgrade-module-path"];
/// The separator of a `-cp` value on this (client) machine.
pub fn classpath_separator() -> char {
if cfg!(windows) { ';' } else { ':' }
}
fn split_path_list(value: &str) -> Vec<&str> {
value.split(classpath_separator()).filter(|e| !e.is_empty()).collect()
}
/// Absolute classpath entries of `args` that live outside the project root.
/// Unresolved placeholders (`${…}`), wildcards and relative entries pass through:
///
/// callers map the project root already, and only concrete local files can be synced.
pub fn external_classpath_entries(args: &[String], root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if PATH_LIST_FLAGS.contains(&args[i].as_str()) && i + 1 < args.len() {
let value = &args[i + 1];
if !value.contains("${") {
for entry in split_path_list(value) {
if entry.contains('*') {
continue;
}
let path = PathBuf::from(entry);
if path.is_absolute() && !path.starts_with(root) && !out.contains(&path) {
out.push(path);
}
}
}
i += 2;
} else {
i += 1;
}
}
out
}
/// The remote home of an outside-root dependency: mirrored under `.remote-libs`
/// (never `.openidea*`: deployment excludes those names), so the layout stays
/// debuggable and collision-free per client machine.
pub fn external_lib_remote(remote_root: &str, local: &Path) -> String {
let spelling = local.to_string_lossy().replace('\\', "/");
let stripped = spelling.trim_start_matches('/').replace(':', "");
join(remote_root.trim_end_matches('/'), &format!(".remote-libs/{stripped}"))
}
/// Rewrites the outside-root classpath entries of `args` onto their synced remote
/// copies; returns the new args and the (local entry, remote base) pairs to upload.
/// Entries inside the project root are untouched: `target_command` maps those.
pub fn rewrite_external_classpath(
args: &[String],
root: &Path,
remote_root: &str,
) -> (Vec<String>, Vec<(PathBuf, String)>) {
let mut table: Vec<(PathBuf, String)> = Vec::new();
let mut out = args.to_vec();
let mut i = 0;
while i < out.len() {
if PATH_LIST_FLAGS.contains(&out[i].as_str()) && i + 1 < out.len() {
let value = out[i + 1].clone();
if !value.contains("${") {
let mut rewritten = Vec::new();
let mut changed = false;
for entry in split_path_list(&value) {
let path = PathBuf::from(entry);
if !entry.contains('*') && path.is_absolute() && !path.starts_with(root) {
let remote = match table.iter().find(|(p, _)| p == &path) {
Some((_, r)) => r.clone(),
None => {
let r = external_lib_remote(remote_root, &path);
table.push((path, r.clone()));
r
}
};
rewritten.push(remote);
changed = true;
} else {
rewritten.push(entry.to_owned());
}
}
if changed {
out[i + 1] = rewritten.join(&classpath_separator().to_string());
}
}
i += 2;
} else {
i += 1;
}
}
(out, table)
}
fn target_command(
config: &RunConfig,
target: &RunTarget,
@@ -480,51 +381,4 @@ mod tests {
assert_eq!(replace_root("/work/proj", "/work/proj", "/srv"), "/srv");
assert_eq!(replace_root(r"C:\p\src/a.py C:\p2", r"C:\p", "/srv"), r"/srv/src/a.py C:\p2");
}
/// Unix/Windows spellings of the same layout: one inside-root entry, one
/// `~/.m2` jar twice, a placeholder value, a wildcard and a module path.
fn classpath_fixture() -> (PathBuf, Vec<String>, String, String) {
let (root, classes, m2) = if cfg!(windows) {
(r"C:\proj", r"C:\proj\target\classes", r"C:\Users\me\.m2\a.jar")
} else {
("/proj", "/proj/target/classes", "/home/me/.m2/a.jar")
};
let sep = classpath_separator();
let args = vec![
"-cp".to_owned(),
format!("{classes}{sep}{m2}{sep}{m2}"),
"-p".to_owned(),
m2.to_owned(),
"-cp".to_owned(),
"${classpath}".to_owned(),
"-cp".to_owned(),
"/elsewhere/lib/*".to_owned(),
];
(PathBuf::from(root), args, m2.to_owned(), classes.to_owned())
}
#[test]
fn collects_outside_root_classpath_entries_once() {
let (root, args, m2, _) = classpath_fixture();
let entries = external_classpath_entries(&args, &root);
assert_eq!(entries, vec![PathBuf::from(m2)]);
}
#[test]
fn rewrites_outside_root_entries_onto_remote_lib_copies() {
let (root, args, m2, classes) = classpath_fixture();
let (rewritten, table) = rewrite_external_classpath(&args, &root, "/srv/app");
let remote = if cfg!(windows) {
"/srv/app/.remote-libs/C/Users/me/.m2/a.jar".to_owned()
} else {
"/srv/app/.remote-libs/home/me/.m2/a.jar".to_owned()
};
assert_eq!(table, vec![(PathBuf::from(m2), remote.clone())]);
let sep = classpath_separator();
assert_eq!(rewritten[1], format!("{classes}{sep}{remote}{sep}{remote}"));
assert_eq!(rewritten[3], remote);
// Placeholders and wildcards pass through untouched.
assert_eq!(rewritten[5], "${classpath}");
assert_eq!(rewritten[7], "/elsewhere/lib/*");
}
}
-35
View File
@@ -59,38 +59,3 @@ for (const { width, split } of [{ width: 220 }, { width: 280 }, { width: 520 },
await expect(terminal.locator(".xterm-helper-textarea:focus")).toHaveCount(1);
});
}
test("terminal highlights visible occurrences of selected text in a single overlay layer", async ({
workbench: page,
}) => {
await page.evaluate(async () => {
const path = "/src/workbench/toolwindows/store.ts";
const layout = await import(path);
layout.showToolWindow("terminal", true);
});
const terminal = page.getByRole("region", { name: "Terminal", exact: true });
await expect(terminal.locator(".xterm-helper-textarea")).toBeFocused();
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(r));
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
const s = activeSession()!;
s.term.reset();
await new Promise<void>((resolve) => s.term.write("needle other needle\r\nneedle gamma gamma\r\n", resolve));
s.term.select(0, 0, 6);
});
await expect(terminal.locator(".openidea-terminal-selection-matches")).toHaveCount(1);
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(2);
if (process.env.OPENIDEA_TERMINAL_SHOTS)
await terminal.screenshot({ path: `${process.env.OPENIDEA_TERMINAL_SHOTS}/terminal-selection-matches.png` });
await page.evaluate(async () => {
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
activeSession()!.term.clearSelection();
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(0);
});
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.41",
"version": "0.1.40",
"license": "Apache-2.0",
"type": "module",
"scripts": {
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// The commit being prepared in the window the current command or event came
/// from (the Commit tool window, or the Commit dialog while it is open): the
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Plugin-owned project data sources. Requires database.sources and project trust.
/// Runs on the Database subsystem's host, including on remote project servers.
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside
/// an open project. Sets belong to the plugin and disappear when it stops.
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// What the pane beside the Services tree shows for a plugin's node.
interface detail-types {
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Optional export (world `plugin-diagnostics`): problems in the current,
/// possibly unsaved document, for the languages declared with the
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
@@ -32,9 +32,6 @@ interface events {
/// API 1.4 also delivers `file-closed` (JSON path string), `settings-changed`
/// (JSON array of the plugin's changed setting keys), and `input-mode-changed`
/// (JSON {path, mode, sub}) through `other`. The last needs `events.input`.
/// API 1.5 also delivers `extensions-changed` (JSON array of the extension
/// points this plugin declares whose contributions changed) through `other`,
/// to the declaring plugin only and without a capability.
/// Kinds: `file-opened`, `file-saved`, `files-changed` (batched disk changes
/// as `other`, with created/modified/removed path lists in JSON), `editor-focused` (the focused editor
/// changed), `vcs-changed` (a repository's state changed: commit,
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
interface lifecycle {
use types.{context, error};
@@ -73,22 +73,3 @@ interface syntax-provider {
outline: func(language: string, text: string) -> list<symbol>;
folding: func(language: string, text: string) -> list<fold>;
}
/// Optional pre-launch gate for targets owned by this plugin (API 1.5).
interface run-target-validation {
enum operation { run, test }
record launch-info {
operation: operation,
/// Bare program name before wrapping; no arguments or environment.
program: string,
/// Absolute working directory on the machine hosting the plugin.
cwd: string,
}
/// Called after trust, ownership, process grants and executable pin checks.
/// An error refuses the launch without changing the selected target. The
/// host prefixes and bounds the message. Missing exports allow the launch.
/// Calls wake reclaimed instances, use the host call timeout (5s by default),
/// and are cancelled with the launch. Traps/timeouts refuse the launch.
/// For remote projects this runs on the server, where the plugin lives.
validate: func(project: string, target-id: string, launch: launch-info) -> result<_, string>;
}
@@ -1,21 +0,0 @@
package openidea:[email protected];
/// What other plugins contributed to the extension points this plugin declares
/// (`[[extension-points]]` in `plugin.toml`; the contributors list this plugin
/// in `[[depends]]`). A change is announced by the `extensions-changed` event.
interface extensions {
use types.{error};
record contribution {
/// The contributing plugin's id.
plugin: string,
/// Its `[[contributes."<point>"]]` table as JSON, checked by the host
/// against the fields the point declares.
data: string,
}
/// The contributions to `point`, in the order the plugins started. Only the
/// plugin that declares `point` may read it (`denied` otherwise); `not-found`
/// when no running plugin declares it.
contributions: func(point: string) -> result<list<contribution>, error>;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Optional export (world `plugin-file-actions`): quick fixes and
/// refactorings that create, rename or delete files, next to the text edits
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Files outside the open projects: the configuration of the program a
/// plugin integrates with, a tool installed elsewhere, a licence file.
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -9,7 +9,6 @@ interface log {
interface ui {
use types.{error, notification-level};
use workspace.{project};
record status-item {
text: string,
@@ -51,12 +50,6 @@ interface ui {
/// dismisses the balloon and runs the command in its window. An invalid
/// action or an undeclared command fails the call and shows nothing.
notify: func(level: notification-level, title: string, body: string, actions: list<notification-action>) -> result<_, error>;
/// `notify` in a window of `target` only (API 1.5), with or without an
/// invoking window: the most recently focused one when the project is open
/// in several. Actions run in that window. A project closed by the time of
/// the call fails it with `not-found` and shows nothing; there is no
/// fallback to another window. A shown balloon goes away with its window.
notify-project: func(target: borrow<project>, level: notification-level, title: string, body: string, actions: list<notification-action>) -> result<_, error>;
/// Shows or updates the plugin's status bar item `id` in every window.
set-status-item: func(id: string, item: status-item);
remove-status-item: func(id: string);
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Optional export (world `plugin-hover`): Markdown added to the hover of
/// documents of the languages declared with the `hover` provider kind,
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Cross-file associations a plugin publishes for the files it declares under
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Embedded languages. The host routes plugin providers and configured language
/// servers inside these ranges, preserving host-document UTF-16 coordinates.
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
/// the languages declared with the `inlay-hints` provider kind, including
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Additional language features, called only for declared API 1.4 provider kinds.
/// Editor calls have a 50 ms soft / 500 ms hard budget; formatting has 5 seconds.
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Discovery of the IDE's built-in MCP server; never exposes its URL or token.
interface mcp {
@@ -8,7 +8,7 @@
/// `fs` and `workspace`, and everything else goes through the imports below,
/// gated by the capabilities granted in `plugin.toml` (a missing one returns
/// `error::denied`).
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
world plugin {
import log;
@@ -37,7 +37,6 @@ world plugin {
import database;
import mcp;
import settings;
import extensions;
export lifecycle;
export commands;
@@ -201,8 +200,3 @@ world language-injections { export injections; }
world plugin-mcp { include plugin; export mcp-provider; }
/// Export-only MCP tools, composed with the base plugin world.
world mcp-tools { export mcp-provider; }
/// A plugin that validates launches through its run targets (API 1.5).
world plugin-run-target-validation { include plugin; export run-target-validation; }
/// Export-only validation, composed with the base plugin world.
world run-target-validator { export run-target-validation; }
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Project-scoped targets owned by the registering plugin (API 1.4).
interface run-targets {
@@ -16,7 +16,10 @@ interface run-targets {
/// Without workdir-args the wrapper manages its container directory itself.
/// The wrapper always starts in the configuration's LOCAL working directory.
/// Environment reaches the wrapper; forwarding into a container depends on its conventions.
/// Registration never selects the target. Debugging plugin targets is invalid.
/// Registration never selects the target. Debugging requires container-root and an
/// explicitly supported language debugger placement. Connect-back adapters listen
/// on the host; the target must configure the debuggee to connect to that host.
/// Stdio adapters run inside the target and must be installed there.
record run-target {
id: string,
name: string,
@@ -31,29 +34,6 @@ interface run-targets {
/// Targets survive idle reclamation, and disappear on unregister or plugin stop.
/// Remote project registration and execution take place on the server.
register: func(project: borrow<project>, target: run-target) -> result<_, error>;
/// Per-command prefix and optional replacement working-directory arguments (API 1.5).
/// None inherits the target's workdir-args; some([]) omits them.
/// Replacement arguments use the same {cwd} substitution and path restrictions.
record command-override {
name: string,
args: list<string>,
workdir-args: option<list<string>>,
}
/// Like run-target, with per-command working-directory argument control (API 1.5).
/// Matching commands use program + override args + selected workdir-args + command arguments.
record run-target-with-overrides {
id: string,
name: string,
program: string,
args: list<string>,
workdir-args: list<string>,
container-root: option<string>,
commands: list<command-override>,
}
/// Same ownership, trust, process grant and pin checks as register (API 1.5).
register-with-overrides: func(project: borrow<project>, target: run-target-with-overrides) -> result<_, error>;
/// Removes only this plugin's target from the project; missing ids are harmless.
unregister: func(project: borrow<project>, id: string);
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Launches this plugin's configurations in the invoking window's Run tool window.
interface run {
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Optional export (world `plugin-semantic-tokens`): semantic colouring of
/// documents of the languages declared with the `semantic-tokens` provider
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
@@ -1,4 +1,4 @@
package openidea:plugin@1.5.0;
package openidea:plugin@1.4.0;
interface workspace {
use types.{error, location};
@@ -66,12 +66,6 @@ interface workspace {
/// contents win over the disk. A missing path is `not-found`.
/// Requires `project.read`.
read-file: func(path: string) -> result<string, error>;
/// Reads the bytes of a file inside the project (absolute, or
/// relative to the root) — images, archives, fonts — at most 16 MiB,
/// as `fs.read-bytes` allows; paths leaving the root are refused and
/// a missing path is `not-found`. The file on disk: unsaved editor
/// contents don't apply. Requires `project.read`.
read-bytes: func(path: string) -> result<list<u8>, error>;
/// Replaces the text of a file inside the project (absolute, or
/// relative to the root), or creates it with missing parent
/// directories; paths leaving the root are refused. Written in one
+1 -20
View File
@@ -4,7 +4,7 @@ use crate::state::AppState;
use ide_agent::registry::AgentRegistry;
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
ExternalSession, McpServer, McpServerInput, NewAgentTask,
McpServer, McpServerInput, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath, RequestId};
use ide_host::ops::agent;
@@ -166,25 +166,6 @@ pub async fn agent_create(window: Window, state: State<'_, AppState>, request: N
agent::create(&state.host, window.label(), request).await
}
/// Sessions on this machine that no task holds yet, newest first (#312).
#[tauri::command]
#[specta::specta]
pub fn agent_external_sessions(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ExternalSession>> {
agent::external_sessions(&state.host, window.label())
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
#[tauri::command]
#[specta::specta]
pub fn agent_import_session(
window: Window,
state: State<'_, AppState>,
provider: String,
session_id: String,
) -> IdeResult<AgentTask> {
agent::import_session(&state.host, window.label(), provider, session_id)
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
#[tauri::command]
#[specta::specta]
-224
View File
@@ -1,224 +0,0 @@
//! GTK file chooser for Linux sessions where the dialog plugin has nothing to show.
//!
//! The dialog plugin is built with rfd's `xdg-portal` backend so KDE and GNOME show their own
//! chooser. Without a portal service and without `zenity`, rfd returns nothing and the UI reads
//! that as a cancelled dialog. The frontend asks `file_dialog_needs_gtk` once, and routes the
//! pickers through `gtk_pick_paths` when it answers true. GTK3 is already linked by the webview.
use ide_core::{IdeError, IdeResult, IpcPath};
use serde::Deserialize;
use specta::Type;
use std::path::PathBuf;
#[derive(Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub struct GtkFilter {
pub name: String,
pub extensions: Vec<String>,
}
#[derive(Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub struct GtkPickOptions {
pub title: Option<String>,
pub default_path: Option<PathBuf>,
pub directory: bool,
pub multiple: bool,
/// A save dialog: one path, with overwrite confirmation.
pub save: bool,
pub filters: Vec<GtkFilter>,
}
/// True when the plugin's dialogs can't be shown here: Linux with no portal service on the session
/// bus (running or activatable) and no `zenity`.
#[tauri::command]
#[specta::specta]
pub async fn file_dialog_needs_gtk() -> bool {
#[cfg(target_os = "linux")]
{
tauri::async_runtime::spawn_blocking(|| !linux::portal_reachable() && !linux::zenity_installed())
.await
.unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
/// Shows a GTK file chooser attached to `window`. `None` when the user cancels.
#[tauri::command]
#[specta::specta]
pub async fn gtk_pick_paths(window: tauri::Window, options: GtkPickOptions) -> IdeResult<Option<Vec<IpcPath>>> {
#[cfg(target_os = "linux")]
{
let (tx, rx) = std::sync::mpsc::channel();
window
.run_on_main_thread({
let window = window.clone();
move || {
let _ = tx.send(linux::pick(
window.gtk_window().ok().map(gtk::prelude::Cast::upcast::<gtk::Window>).as_ref(),
&options,
));
}
})
.map_err(|e| IdeError::internal(e.to_string()))?;
let picked = tauri::async_runtime::spawn_blocking(move || rx.recv())
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
Ok(picked.map_err(|e| IdeError::internal(e.to_string()))?.map(|paths| paths.into_iter().map(IpcPath).collect()))
}
#[cfg(not(target_os = "linux"))]
{
let _ = (window, options);
Err(IdeError::unavailable("The GTK file chooser is only available on Linux"))
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::GtkPickOptions;
use gtk::gio::{self, prelude::*};
use gtk::prelude::*;
use std::path::PathBuf;
const PORTAL: &str = "org.freedesktop.portal.Desktop";
fn bus_call(
connection: &gio::DBusConnection,
method: &str,
parameters: Option<&gtk::glib::Variant>,
) -> Option<gtk::glib::Variant> {
connection
.call_sync(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
method,
parameters,
None,
gio::DBusCallFlags::NONE,
2000,
gio::Cancellable::NONE,
)
.ok()
}
/// Whether `org.freedesktop.portal.Desktop` is running or can be started on demand.
pub fn portal_reachable() -> bool {
let Ok(connection) = gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE) else {
return false;
};
let running = bus_call(&connection, "NameHasOwner", Some(&(PORTAL,).to_variant()))
.and_then(|v| v.child_value(0).get::<bool>())
.unwrap_or(false);
running
|| bus_call(&connection, "ListActivatableNames", None)
.and_then(|v| v.child_value(0).get::<Vec<String>>())
.is_some_and(|names| names.iter().any(|n| n == PORTAL))
}
pub fn zenity_installed() -> bool {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join("zenity").is_file()))
}
pub fn pick(parent: Option<&gtk::Window>, options: &GtkPickOptions) -> Option<Vec<PathBuf>> {
let action = match (options.save, options.directory) {
(true, _) => gtk::FileChooserAction::Save,
(false, true) => gtk::FileChooserAction::SelectFolder,
(false, false) => gtk::FileChooserAction::Open,
};
let accept = if options.save { "_Save" } else { "_Open" };
let dialog = gtk::FileChooserDialog::with_buttons(
Some(options.title.as_deref().unwrap_or(if options.save { "Save" } else { "Open" })),
parent,
action,
&[("_Cancel", gtk::ResponseType::Cancel), (accept, gtk::ResponseType::Accept)],
);
dialog.set_select_multiple(options.multiple && !options.save);
dialog.set_do_overwrite_confirmation(options.save);
if let Some(default) = &options.default_path {
if default.is_dir() {
let _ = dialog.set_current_folder(default);
} else {
if let Some(parent) = default.parent().filter(|p| p.is_dir()) {
let _ = dialog.set_current_folder(parent);
}
if options.save {
if let Some(name) = default.file_name() {
dialog.set_current_name(&name.to_string_lossy());
}
} else if default.is_file() {
let _ = dialog.set_filename(default);
}
}
}
for filter in options.filters.iter().filter(|f| !f.extensions.is_empty()) {
let gtk_filter = gtk::FileFilter::new();
gtk_filter.set_name(Some(&filter.name));
for extension in &filter.extensions {
if extension == "*" {
gtk_filter.add_pattern("*");
} else {
// GTK patterns are case sensitive; match `.TXT` as well as `.txt`.
let ext: String = extension
.chars()
.map(|c| {
if c.is_ascii_alphabetic() {
format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase())
} else {
c.to_string()
}
})
.collect();
gtk_filter.add_pattern(&format!("*.{ext}"));
}
}
dialog.add_filter(gtk_filter);
}
let response = dialog.run();
let paths = (response == gtk::ResponseType::Accept).then(|| dialog.filenames());
// SAFETY: the dialog is ours, and not used after this.
unsafe { dialog.destroy() };
paths.filter(|p| !p.is_empty())
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use gtk::prelude::*;
/// Needs a display and no portal service, e.g. `xvfb-run` with no session bus.
#[test]
#[ignore = "needs a display"]
fn gtk_chooser_opens_without_a_portal_or_zenity() {
gtk::init().expect("a display");
assert!(!linux::portal_reachable(), "run without a session bus");
let dir = std::env::temp_dir();
let options = GtkPickOptions {
title: Some("Open Folder".into()),
default_path: Some(dir.clone()),
directory: true,
multiple: false,
save: false,
filters: vec![],
};
// Accept the chooser once it is on screen, and note its title.
gtk::glib::timeout_add_local(std::time::Duration::from_millis(500), || {
let chooser =
gtk::Window::list_toplevels().into_iter().find_map(|w| w.downcast::<gtk::FileChooserDialog>().ok());
let chooser = chooser.expect("the chooser is shown");
assert_eq!(chooser.title().as_deref(), Some("Open Folder"));
assert!(chooser.is_visible());
chooser.response(gtk::ResponseType::Accept);
gtk::glib::ControlFlow::Break
});
let picked = linux::pick(None, &options).expect("the chooser returned the accepted folder");
assert_eq!(picked, vec![dir]);
}
}
-6
View File
@@ -22,7 +22,6 @@ pub mod deploy;
pub mod doc;
pub mod docker;
pub mod env;
pub mod file_dialog;
pub mod fonts;
pub mod fs;
pub mod github;
@@ -67,8 +66,6 @@ pub(crate) fn sender<T: tauri::ipc::IpcResponse + Send + Sync + 'static>(
pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new().commands(collect_commands![
app::app_info,
file_dialog::file_dialog_needs_gtk,
file_dialog::gtk_pick_paths,
mcp::mcp_ui_ready,
mcp::mcp_respond,
mcp::mcp_server_status,
@@ -131,8 +128,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
agent::agent_commands,
agent::agent_entries,
agent::agent_create,
agent::agent_external_sessions,
agent::agent_import_session,
agent::agent_prompt,
agent::agent_unqueue,
agent::agent_send_queued_now,
@@ -385,7 +380,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_import_sources,
run::run_import_configs,
run::run_system_env,
run::run_env_files,
run::run_start,
run::run_stop,
run::run_stdin,
-11
View File
@@ -179,17 +179,6 @@ pub fn run_system_env(state: State<'_, AppState>) -> std::collections::BTreeMap<
ide_host::ops::run::run_system_env(&state.host, "")
}
/// Variables of the `.env` files a debug launch names, for the debugger to start with.
#[tauri::command]
#[specta::specta]
pub fn run_env_files(
window: Window,
state: State<'_, AppState>,
files: Vec<std::path::PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
ide_host::ops::run::run_env_files(&state.host, window.label(), files)
}
/// Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path.
#[tauri::command]
#[specta::specta]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.41",
"version": "0.1.40",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
-42
View File
@@ -8,13 +8,6 @@ import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core";
/** Commands */
export const commands = {
appInfo: () => __TAURI_INVOKE<AppInfo>("app_info"),
/**
* True when the plugin's dialogs can't be shown here: Linux with no portal service on the session
* bus (running or activatable) and no `zenity`.
*/
fileDialogNeedsGtk: () => __TAURI_INVOKE<boolean>("file_dialog_needs_gtk"),
/** Shows a GTK file chooser attached to `window`. `None` when the user cancels. */
gtkPickPaths: (options: GtkPickOptions) => typedError<IpcPath[] | null, IdeError>(__TAURI_INVOKE("gtk_pick_paths", { options })),
mcpUiReady: (ready: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_ui_ready", { ready })),
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
@@ -169,10 +162,6 @@ export const commands = {
/** Entries of `task`'s conversation, or of its subagent `subagent`'s. */
agentEntries: (task: string, subagent: string | null) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task, subagent })),
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
/** Sessions on this machine that no task holds yet, newest first (#312). */
agentExternalSessions: () => typedError<ExternalSession[], IdeError>(__TAURI_INVOKE("agent_external_sessions")),
/** Imports an external session as a task: its first turn loads and replays it (#312). */
agentImportSession: (provider: string, sessionId: string) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_import_session", { provider, sessionId })),
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
agentPrompt: (task: string, prompt: AgentPrompt, now: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_prompt", { task, prompt, now })),
/** Drops a queued message and returns it (to edit it). */
@@ -744,8 +733,6 @@ export const commands = {
runImportConfigs: (source: RunImportSource, existing: RunConfig_Deserialize[]) => typedError<RunImport_Serialize, IdeError>(__TAURI_INVOKE("run_import_configs", { source, existing })),
/** The environment runs inherit when "Include system environment variables" is on. */
runSystemEnv: () => __TAURI_INVOKE<{ [key in string]: string }>("run_system_env"),
/** Variables of the `.env` files a debug launch names, for the debugger to start with. */
runEnvFiles: (files: string[]) => typedError<{ [key in string]: string }, IdeError>(__TAURI_INVOKE("run_env_files", { files })),
runStart: (config: RunConfig_Deserialize, onOutput: Channel<OutputChunk>, request: number | null) => typedError<RunInfo, IdeError>(__TAURI_INVOKE("run_start", { config, onOutput, request })),
runStop: (id: RunId) => typedError<null, IdeError>(__TAURI_INVOKE("run_stop", { id })),
runStdin: (id: RunId, data: string) => typedError<null, IdeError>(__TAURI_INVOKE("run_stdin", { id, data })),
@@ -4310,19 +4297,6 @@ export type ExternalFormatterSettings = {
runOnSave?: boolean,
};
/** A session on this machine that no task holds yet. */
export type ExternalSession = {
/** The [`crate::AgentProvider`] id that owns it (`claude`, `codex`). */
provider: string,
/** The id resuming it names (`--resume`, `thread/resume`). */
sessionId: string,
title: string,
/** Where it ran, when the transcript names one. */
cwd: string | null,
/** File modification time, ms since the epoch. */
updatedMs: number | null,
};
/** Tools › External Tools entry. */
export type ExternalTool = {
id?: string,
@@ -4901,21 +4875,6 @@ export type GradleTask = {
description: string | null,
};
export type GtkFilter = {
name: string,
extensions: string[],
};
export type GtkPickOptions = {
title: string | null,
defaultPath: string | null,
directory: boolean,
multiple: boolean,
/** A save dialog: one path, with overwrite confirmation. */
save: boolean,
filters: GtkFilter[],
};
/** GutterAction data. */
export type GutterAction = {
line: number,
@@ -8850,7 +8809,6 @@ export type TerminalSettings = {
overrideIdeShortcuts?: boolean,
shellIntegration?: boolean,
highlightHyperlinks?: boolean,
highlightSelection?: boolean,
optionAsMeta?: boolean,
/** Escape moves focus to the editor instead of reaching the shell. */
escapeToEditor?: boolean,
+1 -1
View File
@@ -26,7 +26,7 @@ async function retrieve(path: string, choose: boolean) {
try {
let sources: string | null = null;
if (choose) {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: tx("Choose Sources…"),
filters: [{ name: tx("Source archives"), extensions: ["jar", "zip"] }],
-12
View File
@@ -187,15 +187,3 @@
border-bottom: 1px solid var(--border);
font-weight: 600;
}
/* Imported sessions below the task list: compact rows, not growing space. */
.external {
flex: none;
overflow-y: auto;
padding: 4px 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.external > .row {
flex: none;
}
+1 -45
View File
@@ -25,7 +25,7 @@ import {
sessionKey,
type TaskGroup,
} from "./model";
import { importExternalSession, openHome, refreshExternalSessions, useAgents } from "./store";
import { openHome, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import { SessionEditor } from "./SessionEditor";
import { HomeEditor } from "./HomeEditor";
@@ -93,19 +93,6 @@ export function AgentsView() {
const inline = docked !== null || homeOpen;
const historyRef = useRef<HTMLButtonElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const external = useAgents((s) => s.external);
const externalError = useAgents((s) => s.externalError);
const [importing, setImporting] = useState<string | null>(null);
// Sessions started elsewhere (a terminal, another editor) list on demand (#312).
useEffect(() => {
if (historyOpen) void refreshExternalSessions();
}, [historyOpen]);
const importSession = (provider: string, sessionId: string) => {
setImporting(`${provider}:${sessionId}`);
void importExternalSession(provider, sessionId)
.catch(() => {})
.finally(() => setImporting(null));
};
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
const [selected, setSelected] = useState<Set<string>>(() => {
const task = sessionTask(maybeEditorService()?.activeInput?.());
@@ -298,37 +285,6 @@ export function AgentsView() {
<T>Agent sessions</T>
</div>
{taskList}
{(external.length > 0 || externalError) && (
<>
<div className={styles.historyTitle}>
<T>Sessions from other apps</T>
</div>
{externalError ? (
<div className={styles.empty}>{externalError}</div>
) : (
<div className={styles.external}>
{external.map((s) => {
const key = `${s.provider}:${s.sessionId}`;
return (
<div key={key} className={styles.row} title={s.cwd ?? s.sessionId}>
<Icon name={providerIcon(s.provider)} size={16} label={providerName(s.provider)} />
<span className={styles.title}>{s.title}</span>
<span className={styles.age}>{age(s.updatedMs ?? 0)}</span>
<button
type="button"
className={styles.link}
disabled={importing === key}
onClick={() => importSession(s.provider, s.sessionId)}
>
<T>Import</T>
</button>
</div>
);
})}
</div>
)}
</>
)}
</Popup>
)}
</div>
+38 -28
View File
@@ -54,6 +54,10 @@
align-items: center;
gap: 2px;
}
.statusActions > .changesToggle {
height: var(--h-control-small);
padding: 0 4px;
}
.statusText {
flex: 1 1 0;
min-width: 0;
@@ -467,31 +471,6 @@
.markdown :global(ol) {
margin: 0 0 8px;
padding-left: 20px;
list-style: none;
}
/* Markers are drawn as pseudo-elements: WebKit paints a selection box around a native marker,
leaving a broken stripe down the gutter when a list is selected. */
.markdown :global(li) {
position: relative;
}
.markdown :global(li)::before {
position: absolute;
right: calc(100% + 6px);
-webkit-user-select: none;
user-select: none;
}
.markdown :global(ul > li)::before {
content: "•";
}
.markdown :global(ul ul > li)::before {
content: "◦";
}
.markdown :global(ul ul ul > li)::before {
content: "▪";
}
.markdown :global(ol > li)::before {
content: counter(list-item) ".";
font-variant-numeric: tabular-nums;
}
.markdown :global(a) {
color: var(--link);
@@ -542,9 +521,7 @@
}
.markdown :global(li:has(> input[type="checkbox"])) {
margin-left: -20px;
}
.markdown :global(li:has(> input[type="checkbox"]))::before {
content: none;
list-style: none;
}
.markdown > :global(:first-child) {
margin-top: 0;
@@ -1253,6 +1230,33 @@
text-overflow: ellipsis;
}
.changesToggle {
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--h-control);
padding: 0 8px;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-subtle);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.changesToggle:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--fg-strong);
}
.changesToggle[aria-pressed="true"] {
background: var(--bg-selected);
color: var(--fg-strong);
}
.changesToggle:disabled {
opacity: 0.4;
cursor: default;
}
/* An open AskUserQuestion: every question with its options, answered at once above the composer. */
.questionCard {
flex: none;
@@ -1542,6 +1546,12 @@
.planCurrent {
color: var(--fg);
}
.trayActivity {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A one-button toolbar, not a round bubble: the popup's large arc would turn it into a circle. */
.quotePopup {
display: flex;
+47 -5
View File
@@ -75,7 +75,7 @@ import { ConfigPickers } from "./ConfigPickers";
import { findRanges, formatDuration, formatSentAt } from "./conversation";
import { ImagePreview, imageSrc } from "./ImagePreview";
import { useAgentSettings } from "./agentSettings";
import { foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
import { currentTool, foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
import { useRevealedText } from "./reveal";
import { AgentReply, MessageActions, ReplyActions } from "./AgentReply";
import {
@@ -272,8 +272,20 @@ function useLocalHead(status: AgentTask["status"] | null, root: string | null):
return local ? head : null;
}
/** The task's terminal, at the end of its status line. */
function StatusActions({ task }: { task: AgentTask }) {
/** The task's terminal and changes, at the end of its status line. */
function StatusActions({
task,
changes,
changesOpen,
onToggleChanges,
}: {
task: AgentTask;
changes: AgentChange[] | null;
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const tx = useTx();
const count = changes?.length ?? 0;
return (
<span className={styles.statusActions}>
<IconButton
@@ -282,6 +294,18 @@ function StatusActions({ task }: { task: AgentTask }) {
title="Terminal"
onClick={() => void executeAction("terminal.openAt", task.cwd)}
/>
<button
type="button"
className={styles.changesToggle}
aria-pressed={changesOpen}
disabled={count === 0 && !changesOpen}
aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")}
title={changesOpen ? tx("Hide changes") : tx("Show changes")}
onClick={onToggleChanges}
>
<Icon name="toolwindow-changes" size={14} />
{count > 0 && <span>{count}</span>}
</button>
</span>
);
}
@@ -291,10 +315,16 @@ function SessionStatus({
task,
entries,
head,
changes,
changesOpen,
onToggleChanges,
}: {
task: AgentTask;
entries: AgentEntry[] | null;
head: string | null;
changes: AgentChange[] | null;
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const tx = useTx();
const project = useProject((s) => s.project);
@@ -327,7 +357,7 @@ function SessionStatus({
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
</span>
<Meters task={task} entries={entries} />
<StatusActions task={task} />
<StatusActions task={task} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges} />
</div>
);
}
@@ -2358,6 +2388,7 @@ function Tray({
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
const busy = task.status === "running" || task.status === "starting";
const tool = entries && busy ? currentTool(entries) : null;
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
return (
<div className={styles.tray}>
@@ -2414,6 +2445,10 @@ function Tray({
<Spinner size={12} />
{task.retry ? (
<Retrying retry={task.retry} />
) : tool ? (
<span className={styles.trayActivity} title={tool.title}>
{shortTitle(task, tool.title || tool.kind)}
</span>
) : (
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
)}
@@ -2524,7 +2559,14 @@ function SessionComposer({
);
return (
<div className={styles.composerArea}>
<SessionStatus task={task} entries={entries} head={head} />
<SessionStatus
task={task}
entries={entries}
head={head}
changes={changes}
changesOpen={changesOpen}
onToggleChanges={onToggleChanges}
/>
{task.status === "archived" && (
<div className={styles.banner}>
<span className={styles.bannerText}>
+1 -1
View File
@@ -1,5 +1,5 @@
/** Agents of the ACP Registry: listing, install / update / remove (`agent_registry_*`). */
import { open } from "@/platform/dialog";
import { open } from "@tauri-apps/plugin-dialog";
import { Channel } from "@tauri-apps/api/core";
import type {
AgentProvider,
+2 -59
View File
@@ -22,15 +22,6 @@ const backend = vi.hoisted(() => ({
fetched: [] as string[],
entryError: null as string | null,
entryWait: null as Promise<void> | null,
external: [] as Array<{
provider: string;
sessionId: string;
title: string;
cwd: string | null;
updatedMs: number;
}>,
imported: [] as Array<[string, string]>,
externalError: null as string | null,
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
@@ -49,16 +40,6 @@ vi.mock("@/platform/ipc", async (original) => ({
backend.answers.push([task, permission, option]);
return { status: "ok", data: null };
},
agentExternalSessions: async () => {
if (backend.externalError !== null) throw new Error(backend.externalError);
return { status: "ok", data: backend.external };
},
agentImportSession: async (provider: string, sessionId: string) => {
backend.imported.push([provider, sessionId]);
const found = backend.external.find((e) => e.provider === provider && e.sessionId === sessionId);
if (!found) throw new Error("unknown session");
return { status: "ok", data: { ...task("imported"), id: "imported", title: found.title, provider } };
},
},
}));
vi.mock("@tauri-apps/api/webview", () => ({
@@ -137,9 +118,6 @@ beforeEach(() => {
disconnect();
localStorage.clear();
backend.changes = [];
backend.external = [];
backend.imported = [];
backend.externalError = null;
backend.entries = new Map([
["A", [{ type: "agent", text: "Answer A" }, subagent]],
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
@@ -151,8 +129,6 @@ beforeEach(() => {
homeOpen: false,
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
entries: new Map(backend.entries),
external: [],
externalError: null,
});
});
@@ -1148,14 +1124,14 @@ describe("session state across tasks and hosts", () => {
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
render(<AgentsView />);
await screen.findByText("Answer A");
await screen.findByRole("button", { name: /1 changed file/ });
await waitFor(() => expect(screen.getByRole("button", { name: "Show changes" })).toBeEnabled());
expect(screen.queryByRole("complementary", { name: "Changes" })).toBeNull();
const markVisible = vi.spyOn(await import("./store"), "markVisible");
vi.spyOn(document, "hasFocus").mockReturnValue(true);
const message = screen.getByRole("textbox", { name: "Message" });
fireEvent.change(message, { target: { value: "Draft to keep" } });
const conversation = stream();
fireEvent.click(screen.getByRole("button", { name: /1 changed file/ }));
fireEvent.click(screen.getByRole("button", { name: "Show changes" }));
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
expect(screen.getByText("Answer A")).not.toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
@@ -1280,36 +1256,3 @@ describe("meters in the task header", () => {
expect(screen.getByText("$0.42")).toBeInTheDocument();
});
});
describe("sessions from other apps", () => {
const openHistory = async () => {
act(() => useAgents.setState({ docked: "A" }));
render(<AgentsView />);
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
await waitFor(() => expect(screen.getByText("Sessions from other apps")).toBeInTheDocument());
};
it("lists external sessions in the history popup and imports one as a task", async () => {
backend.external = [
{ provider: "claude", sessionId: "abc", title: "Login fix", cwd: "/work/app", updatedMs: Date.now() },
{ provider: "codex", sessionId: "thread-1", title: "Add tests", cwd: null, updatedMs: 0 },
];
await openHistory();
expect(screen.getByText("Login fix")).toBeInTheDocument();
expect(screen.getByText("Add tests")).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]!);
});
expect(backend.imported).toEqual([["claude", "abc"]]);
const state = useAgents.getState();
expect(state.tasks.get("imported")?.title).toBe("Login fix");
expect(state.external.map((e) => e.sessionId)).toEqual(["thread-1"]);
});
it("shows listing errors instead of the section", async () => {
backend.externalError = "no home";
await openHistory();
expect(screen.getByText("no home")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Import" })).toBeNull();
});
});
-27
View File
@@ -12,7 +12,6 @@ import type {
AgentLimit,
AgentProvider,
AgentTask,
ExternalSession,
Isolation,
} from "@/bindings";
import { useProject } from "@/platform/project";
@@ -78,10 +77,6 @@ interface AgentsState {
lastComposer: string;
/** First messages of past tasks, newest first (New Task page ↑). */
history: string[];
/** Sessions on this machine that no task holds yet (#312). */
external: ExternalSession[];
/** Why the external sessions could not be listed. */
externalError: string | null;
/** The composer asked to take focus (`seq` tells repeated requests apart). */
focus: { key: string; seq: number } | null;
/** Task shown inside the Agents tool window instead of an editor tab. */
@@ -143,8 +138,6 @@ export const useAgents = create<AgentsState>(() => ({
composers: new Map(),
lastComposer: HOME,
history: load<string[]>(HISTORY_KEY, []),
external: [],
externalError: null,
focus: null,
docked: null,
sessionLocation: load<unknown>(SESSION_LOCATION_KEY, null) === "toolWindow" ? "toolWindow" : "editor",
@@ -722,26 +715,6 @@ export async function startTask(
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);
}
/** Sessions on this machine that no task holds yet (a terminal, another editor: #312). */
export async function refreshExternalSessions() {
try {
const external = await call(commands.agentExternalSessions());
useAgents.setState({ external, externalError: null });
} catch (e) {
useAgents.setState({ externalError: e instanceof Error ? e.message : String(e) });
}
}
/** Imports an external session as a task and opens it: its first turn loads it (#312). */
export async function importExternalSession(provider: string, sessionId: string) {
const task = await call(commands.agentImportSession(provider, sessionId));
useAgents.setState((s) => ({
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
external: s.external.filter((e) => !(e.provider === provider && e.sessionId === sessionId)),
}));
openTask(task.id);
}
function remember(text: string) {
if (!text) return;
const history = [text, ...useAgents.getState().history.filter((h) => h !== text)].slice(0, HISTORY_SIZE);
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentEntry } from "@/bindings";
import { foldedTurns, summarizeWork, workParts } from "./timeline";
import { currentTool, foldedTurns, summarizeWork, workParts } from "./timeline";
const user = (text: string, extra: Partial<Extract<AgentEntry, { type: "user" }>> = {}): AgentEntry => ({
type: "user",
@@ -45,6 +45,11 @@ describe("summarizeWork", () => {
{ text: "1 other tool call", count: 1 },
]);
});
it("finds the call in progress", () => {
expect(currentTool([tool("read", { status: "running" }), tool("search")])?.kind).toBe("read");
expect(currentTool([tool("read")])).toBeNull();
});
});
describe("foldedTurns", () => {
+9
View File
@@ -91,6 +91,15 @@ export function workParts(s: WorkSummary): Array<{ text: string; count: number }
return parts.filter(([, , n]) => n > 0).map(([one, many, count]) => ({ text: count === 1 ? one : many, count }));
}
/** The running tool call of a run, if any: what the agent is doing right now. */
export function currentTool(entries: readonly AgentEntry[]): ToolEntry | null {
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!;
if (e.type === "tool" && (e.status === "running" || e.status === "pending")) return e;
}
return null;
}
/** A finished turn: the message that asked it at `user`, its work in `[user + 1, reply)`. */
export interface FoldedTurn {
user: number;
+1 -1
View File
@@ -40,7 +40,7 @@ export function AnalysisDialogHost() {
async function browseDirectory(initial: string): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, defaultPath: initial || undefined });
return typeof picked === "string" ? picked : null;
}
+1 -1
View File
@@ -82,7 +82,7 @@ function NewProjectDialog({ onClose }: { onClose: () => void }) {
const browse = async () => {
if (!isTauri()) return;
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
directory: true,
multiple: false,
+1 -1
View File
@@ -55,7 +55,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
setAdding("");
};
const browse = async (target: "workspace" | "project") => {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const result = await open({
directory: true,
multiple: target === "project",
+2 -2
View File
@@ -56,7 +56,7 @@ function errorText(e: unknown) {
}
async function exportSettings() {
const { save } = await import("@/platform/dialog");
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: "Export Settings",
defaultPath: "openidea-settings.json",
@@ -77,7 +77,7 @@ async function exportSettings() {
}
async function importSettings() {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: "Import Settings",
multiple: false,
+2 -2
View File
@@ -208,14 +208,14 @@ export function defaultProjectDirectory(): string | undefined {
}
async function pickFolder(title: string): Promise<string | null> {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title, defaultPath: defaultProjectDirectory() });
const path = Array.isArray(picked) ? picked[0] : picked;
return path ?? null;
}
async function pickFiles(title: string): Promise<string[]> {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: false, multiple: true, title, defaultPath: defaultProjectDirectory() });
return picked === null ? [] : Array.isArray(picked) ? picked : [picked];
}
+3 -47
View File
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { executeAction, registerAction, allActions, isActionEnabled } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { usePlatform } from "@/platform/platformInfo";
import { GradleView, MavenView } from "./BuildToolView";
import { activate } from "./index";
import { expandableKeys, gradleChildren, gradleRoots, gradleProjectKey } from "./model";
@@ -21,7 +20,6 @@ const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
menu: vi.fn(),
link: vi.fn(),
notify: vi.fn(),
unlink: vi.fn(),
show: vi.fn(),
sync: vi.fn(),
@@ -54,7 +52,6 @@ vi.mock("@tauri-apps/api/core", async (original) => ({
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show: mocks.show }),
notifications: () => ({ notify: mocks.notify }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm }));
@@ -233,12 +230,8 @@ it("re-adds a picked POM using the plus button and preserves other unlinked root
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add Maven Projects" }));
});
await waitFor(() =>
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ defaultPath: "/p", directory: false })),
);
await waitFor(() =>
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["b/pom.xml"]'),
);
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ defaultPath: "/p", directory: false }));
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["b/pom.xml"]');
});
it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/p/a/pom.xml"])(
@@ -250,43 +243,6 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/
},
);
it("links `\\\\?\\`-prefixed and differently-cased picks on Windows", async () => {
const os = usePlatform.getState().os;
usePlatform.setState({ os: "windows" });
try {
useProject.setState({ project: { root: "C:\\Projects\\demo" } as ProjectInfo });
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: [], linkedProjects: [] } } },
});
mocks.open.mockResolvedValue(["\\\\?\\C:\\Projects\\demo\\svc\\pom.xml", "c:/Projects/demo/web/pom.xml"]);
await act(async () => {
await executeAction("maven.addProjects");
});
expect(mocks.set).toHaveBeenCalledWith(
"project",
"languages.java.maven.linkedProjects",
'["svc/pom.xml","web/pom.xml"]',
);
} finally {
usePlatform.setState({ os });
}
});
it("warns about picks outside the project instead of silently doing nothing", async () => {
mocks.open.mockResolvedValue("/other/pom.xml");
await act(async () => {
await executeAction("maven.addProjects");
});
expect(mocks.set).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({
level: "warning",
title: expect.stringContaining("not added"),
body: expect.stringContaining("/other/pom.xml"),
}),
);
});
it("links picked POMs that no reactor lists as independent top-level projects", async () => {
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: ["b/pom.xml"], linkedProjects: ["tools/pom.xml"] } } },
+11 -47
View File
@@ -26,7 +26,6 @@ import {
import { buildToolRecovery } from "@/platform/buildToolRecovery";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
import { hostOs } from "@/platform/remote";
import { editorService, notifications } from "@/workbench/contributions";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import {
@@ -295,46 +294,22 @@ export async function unlinkProjects(pom: string | null) {
await setLinked(root, [pom], false);
}
/** A picked path in one comparable form: slashes unified, `\\?\` prefixes stripped. */
function comparablePath(p: string) {
const slashes = p.replace(/\\/g, "/");
const unc = /^\/\/\?\/UNC\//i.exec(slashes);
if (unc) return `//${slashes.slice(unc[0].length)}`;
return slashes.replace(/^\/\/\?\//, "");
}
/**
* Splits `picked` into project-relative `pom.xml` paths inside `root` and the rest
* (outside the project, not a `pom.xml`, or escaping via `.`/`..`). Drive-letter
* case and `\\?\` prefixes must not matter on Windows, where the dialog may hand
* back either form (#2626).
*/
function projectPoms(root: string, picked: readonly string[]): { inside: string[]; outside: string[] } {
const base = comparablePath(root).replace(/\/$/, "");
const ignoreCase = hostOs() === "windows";
const inside: string[] = [];
const outside: string[] = [];
for (const p of picked) {
const path = comparablePath(p);
const underRoot = ignoreCase
? path.slice(0, base.length + 1).toLowerCase() === `${base.toLowerCase()}/`
: path.startsWith(`${base}/`);
if (!underRoot) {
outside.push(p);
continue;
}
/** Project-relative `pom.xml` paths of `picked` inside `root`; others are skipped. */
function projectPoms(root: string, picked: readonly string[]): string[] {
const base = root.replace(/\\/g, "/").replace(/\/$/, "");
return picked.flatMap((p) => {
const path = p.replace(/\\/g, "/");
if (!path.startsWith(`${base}/`)) return [];
const pom = path.slice(base.length + 1);
const parts = pom.split("/");
if (parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml") outside.push(p);
else inside.push(pom);
}
return { inside, outside };
return parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml" ? [] : [pom];
});
}
export async function addProjects() {
const root = useProject.getState().project?.root;
if (!root || useProject.getState().project?.workspace || !isTauri()) return;
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: tx`Add Maven Projects`,
directory: false,
@@ -342,19 +317,8 @@ export async function addProjects() {
defaultPath: root,
filters: [{ name: "pom.xml", extensions: ["xml"] }],
});
const paths = typeof picked === "string" ? [picked] : (picked ?? []);
if (!paths.length) return;
const { inside: poms, outside } = projectPoms(root, paths);
const poms = projectPoms(root, typeof picked === "string" ? [picked] : (picked ?? []));
if (poms.length) await setLinked(root, poms, true);
// Dropping every pick silently looks exactly like the button doing nothing (#2626).
if (outside.length)
notifications().notify({
group: "tools",
level: "warning",
title: tx`Some Maven projects were not added`,
body: tx`These are outside the open project, and Maven only links POMs inside it: ${outside.slice(0, 3).join(", ")}${outside.length > 3 ? ` (+${outside.length - 3} more)` : ""}`,
key: "maven-add-outside-root",
});
}
/** Links independent Gradle builds without changing their files or build scripts. */
@@ -368,7 +332,7 @@ export async function linkGradleProjects() {
!isTauri()
)
return;
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: tx`Link Gradle Projects`,
directory: false,
+1 -1
View File
@@ -27,7 +27,7 @@ import styles from "./Coverage.module.css";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
async function pickReport(): Promise<string | null> {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({
multiple: false,
directory: false,
+1 -1
View File
@@ -149,7 +149,7 @@ export function ImportDialog({ id, schema, table }: { id: string; schema: string
const choose = async () => {
try {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const f = await open({
multiple: false,
directory: false,
+2 -13
View File
@@ -319,22 +319,11 @@ export function DatabaseView() {
const load = async (n: Node) => {
if (n.kind === "source") {
const id = n.source.id ?? "";
if (!(await connect(id))) {
const err = { status: "error" as const, message: tx("Cannot connect") };
if (listsDatabases(n.source)) useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: err } }));
else useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: err } }));
return;
}
if (!(await connect(id))) return;
if (listsDatabases(n.source)) await loadDatabases(id);
else await loadSchemas(id);
} else if (n.kind === "database") {
if (!(await connect(n.id))) {
useDatabase.setState((s) => ({
schemas: { ...s.schemas, [n.id]: { status: "error", message: tx("Cannot connect") } },
}));
return;
}
await loadSchemas(n.id);
if (await connect(n.id)) await loadSchemas(n.id);
} else if (n.kind === "schema") await loadTables(n.id, n.schema);
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
};
+1 -1
View File
@@ -77,7 +77,7 @@ export function ExportDialog({
const browse = async () => {
try {
const { save } = await import("@/platform/dialog");
const { save } = await import("@tauri-apps/plugin-dialog");
const f = EXPORT_FORMATS.find((x) => x.id === format)!;
const path = await save({ defaultPath: file, filters: [{ name: f.label, extensions: [f.extension] }] });
if (path) setFile(path);
+4 -6
View File
@@ -175,7 +175,7 @@ function blank(): DataSource {
}
async function pickFile(title: string): Promise<string | null> {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title });
return typeof file === "string" ? file : null;
}
@@ -384,8 +384,6 @@ export function SourceDialog({
const vendor = vendorOf(s);
const sqlite = s.driver === "sqlite";
const postgres = s.driver === "postgres";
/** Drivers that can list every database on the host (tree level + Schemas tab). */
const serverDatabases = postgres || s.driver === "sqlServer";
const clickhouse = s.driver === "clickhouse";
const oracle = s.driver === "oracle";
const urlOnly = !sqlite && !!s.urlOnly;
@@ -489,7 +487,7 @@ export function SourceDialog({
const loadNames = async () => {
const id = initial?.id;
if (!id) return;
await Promise.all([serverDatabases ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]);
await Promise.all([postgres ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]);
};
const tabs: Array<{ id: Tab; label: string }> = [
@@ -704,7 +702,7 @@ export function SourceDialog({
fallback={300}
onChange={(autoDisconnectSecs) => setOptions({ autoDisconnectSecs })}
/>
{serverDatabases && (
{postgres && (
<>
<span />
<Checkbox
@@ -929,7 +927,7 @@ export function SourceDialog({
const schemasTab = (
<>
<div className={styles.formWide}>
{serverDatabases && (
{postgres && (
<NamePicker
allLabel={tx("All databases")}
addLabel={tx("Add databases pattern")}
-10
View File
@@ -20,7 +20,6 @@ import {
toCsv,
toTsv,
} from "./model";
import { listsDatabases } from "./store";
import { VENDORS, sourceUrl, vendorById, vendorOf, vendorPreset } from "./vendors";
describe("clipboard formats", () => {
@@ -161,15 +160,6 @@ describe("connection targets", () => {
expect(splitTarget("pg")).toEqual({ id: "pg", database: null });
expect(splitTarget("pg:my:db")).toEqual({ id: "pg", database: "my:db" });
});
it("lists databases under PostgreSQL and SQL Server hosts unless single-database mode", () => {
expect(listsDatabases({ driver: "postgres" })).toBe(true);
expect(listsDatabases({ driver: "sqlServer" })).toBe(true);
expect(listsDatabases({ driver: "postgres", options: { singleDatabase: true } })).toBe(false);
expect(listsDatabases({ driver: "sqlServer", options: { singleDatabase: true } })).toBe(false);
expect(listsDatabases({ driver: "mysql" })).toBe(false);
expect(listsDatabases(undefined)).toBe(false);
});
});
describe("parseSourceUrl", () => {
+1 -1
View File
@@ -47,7 +47,7 @@ interface InTable {
export type DbNode =
| { kind: "source"; key: string; source: DataSource }
/** A database of a PostgreSQL / SQL Server host; `id` is its connection target. */
/** A database of a PostgreSQL server; `id` is its connection target. */
| { kind: "database"; key: string; id: string; name: string; current: boolean }
| { kind: "schema"; key: string; id: string; schema: string }
| { kind: "folder"; key: string; id: string; schema: string; folder: Folder }
+3 -8
View File
@@ -319,13 +319,8 @@ export async function saveSource(
const id = source.id ?? "";
if (password !== undefined) await call(commands.dbSetPassword(id, password, !!source.savePassword));
if (sshSecret !== undefined) await call(commands.dbSetSshSecret(id, sshSecret, !!source.ssh?.saveSecret));
// What the tree had loaded for this source is reloaded with the new settings, so its
// expanded nodes don't stay on Loading.
const st = useDatabase.getState();
const loaded = id in st.databases || Object.keys(st.schemas).some((t) => splitTarget(t).id === id);
forget(id);
await loadSources();
if (loaded) await refresh(id);
else forget(id);
}
/** Copy/Paste › Duplicate: a copy of data source `id` named `<name> [2]` (its password is not copied). */
@@ -502,9 +497,9 @@ export function consoleTints(s: Pick<DatabaseState, "consoles" | "sources">): Ma
return out;
}
/** PostgreSQL / SQL Server data sources show their server's databases (IDEA's data source › database › schema). */
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
export function listsDatabases(source: DataSource | undefined): boolean {
return (source?.driver === "postgres" || source?.driver === "sqlServer") && !source.options?.singleDatabase;
return source?.driver === "postgres" && !source.options?.singleDatabase;
}
/**
+1 -1
View File
@@ -149,7 +149,7 @@ export async function insertScript(n: DbNode, kind: ScriptKind) {
/** SQL Scripts › Run SQL Script…: picks a `.sql` file and runs all its statements. */
async function runScriptFile(id: string) {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({
multiple: false,
directory: false,
+1 -1
View File
@@ -21,7 +21,7 @@ const act = (
const sep: MenuItem = { kind: "separator" };
async function sqliteFromFile() {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "SQLite Database File" });
if (typeof file === "string") openSourceDialog(null, { driver: "sqlite", file, name: fileSourceName(file) });
}
+3 -32
View File
@@ -1,18 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { usePlatform } from "@/platform/platformInfo";
import { debugUnsupportedReason, launchConfigFor, withEnvFiles } from "../launch";
const ipc = vi.hoisted(() => {
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
return { ok, runEnvFiles: vi.fn((_files: string[]) => ok({} as Record<string, string>)) };
});
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const { ok: _ok, ...cmds } = ipc;
return { ...real, commands: { ...real.commands, ...cmds } };
});
import { debugUnsupportedReason, launchConfigFor } from "../launch";
const rc = (p: Partial<RunConfig>): RunConfig => ({
id: "x",
@@ -31,10 +20,7 @@ const rc = (p: Partial<RunConfig>): RunConfig => ({
const extra = (l: { extraJson: string | null } | null) => JSON.parse(l?.extraJson ?? "{}");
const originalPlatform = usePlatform.getState();
beforeEach(() => {
usePlatform.setState({ os: "linux" });
vi.clearAllMocks();
});
beforeEach(() => usePlatform.setState({ os: "linux" }));
afterEach(() => usePlatform.setState(originalPlatform));
describe("run config → launch config", () => {
@@ -508,18 +494,3 @@ it("raw and typed Go debug configurations inherit custom tags unless explicitly
}
}
});
describe("withEnvFiles", () => {
it("leaves configurations without .env files alone (no backend call)", async () => {
const config = rc({ env: { A: "1" } });
await expect(withEnvFiles(config, "/p")).resolves.toBe(config);
expect(ipc.runEnvFiles).not.toHaveBeenCalled();
});
it("loads .env files under the explicit variables, resolved against the project root", async () => {
ipc.runEnvFiles.mockResolvedValueOnce({ status: "ok", data: { FROM_FILE: "file", A: "file" } });
const out = await withEnvFiles(rc({ env: { A: "explicit" }, envFiles: [".env", "env/local.env"] }), "/p");
expect(ipc.runEnvFiles).toHaveBeenCalledWith(["/p/.env", "/p/env/local.env"]);
expect(out.env).toEqual({ FROM_FILE: "file", A: "explicit" });
});
});
+3 -3
View File
@@ -30,7 +30,7 @@ import { fieldAt, methodAt } from "./breakpoints/symbols";
import { toggleBreakpointSmart } from "./breakpoints/toggle";
import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java";
import { cancelHotSwapBuild, reloadChangedClasses } from "./hotswap";
import { debugUnsupportedReason, launchConfigFor, withEnvFiles } from "./launch";
import { debugUnsupportedReason, launchConfigFor } from "./launch";
import {
isPythonAttachTarget,
isRemoteConfig,
@@ -329,7 +329,7 @@ async function startConfig(arg: unknown) {
// php-debug starts the PHP chosen in Settings unless the command names one.
const php =
config.debugAdapter === "php-debug" && isTauri() ? await call(commands.sdkPhpEffective()).catch(() => null) : null;
const launch = launchConfigFor(await withEnvFiles(prepared, root), root, php);
const launch = launchConfigFor(prepared, root, php);
if (!launch) {
notifications().notify({
group: "run",
@@ -399,7 +399,7 @@ export function registerDebugActions(): () => void {
const config = await call(commands.pluginPrepareDebugRun(a.config));
const root = useProject.getState().project?.root;
if (!root) throw new Error("No project");
const launch = launchConfigFor(await withEnvFiles(config, root), root);
const launch = launchConfigFor(config, root);
if (!launch) throw new Error("This configuration has no debugger");
let id: number | null = null;
let stopped = false;
-2
View File
@@ -16,7 +16,6 @@ import {
type JavaPreparation,
} from "@/platform/javaLaunch";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { withEnvFiles } from "./launch";
export const JAVA_ADAPTER = "java-debug";
export const KOTLIN_ADAPTER = "kotlin-debug-adapter";
@@ -112,7 +111,6 @@ export async function javaLaunchConfig(
preparation?: JavaPreparation,
): Promise<LaunchConfig> {
const cwd = rc.cwd ? join(root, rc.cwd) : root;
rc = await withEnvFiles(rc, root);
const parsed = parseJavaArgs(rc.args);
if (!parsed.mainClass) throw new Error(`No main class in '${rc.name}'`);
preparation?.signal.throwIfAborted();
+1 -13
View File
@@ -1,7 +1,7 @@
import { parseArgs, formatArgs } from "@/platform/commandLine";
/** Run configuration → DAP launch configuration, per adapter. */
import { workflowSettings } from "@/platform/workflowSettings";
import { call, commands, type LaunchConfig, type RunConfig } from "@/platform/ipc";
import type { LaunchConfig, RunConfig } from "@/platform/ipc";
import { hostOs } from "@/platform/remote";
function isAbsolute(p: string) {
@@ -37,18 +37,6 @@ function base(rc: RunConfig, adapter: string, cwd: string): LaunchConfig {
};
}
/**
* `rc` with its `.env` files loaded underneath its explicit variables (mirrors Run):
* Debug used to drop them because `base` only copies `env`. Absolute paths go to the
* backend, which parses with the same loader runs use.
*/
export async function withEnvFiles(rc: RunConfig, root: string): Promise<RunConfig> {
const files = rc.envFiles?.filter((f) => f.trim()) ?? [];
if (!files.length) return rc;
const loaded = await call(commands.runEnvFiles(files.map((f) => join(root, f))));
return { ...rc, env: { ...loaded, ...rc.env } };
}
function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
const [cargoArgs, programArgs] = splitAtDashDash(rc.args);
const [sub, ...rest] = cargoArgs;
-23
View File
@@ -129,15 +129,6 @@ export function catalogState(
const vendorOf = (authors: readonly string[]) => authors.join(", ");
/** The marketplace's newer version of an installed plugin, if there is one this IDE can load. */
function useMarketUpdate(plugin: PluginInfo): MarketPlugin | undefined {
return usePlugins((s) =>
plugin.source === "installed"
? s.market?.find((m) => m.id === plugin.id && m.compatible && compareVersions(m.version, plugin.version) > 0)
: undefined,
);
}
function PluginIcon({ id, icon, large }: { id: string; icon: PluginIconData | null | undefined; large?: boolean }) {
return (
<span className={clsx(styles.icon, large && styles.iconLarge)} aria-hidden>
@@ -157,7 +148,6 @@ function ListHeader({ title, count }: { title: string; count: number }) {
function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; selected: boolean; onSelect: () => void }) {
const busy = usePlugins((s) => !!s.busy[plugin.id]);
const update = useMarketUpdate(plugin);
return (
<li className={clsx(styles.row, selected && styles.rowSelected, !plugin.enabled && styles.rowDisabled)}>
<button
@@ -176,7 +166,6 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
{plugin.source !== "installed" && (
<span className={styles.devTag}>{plugin.source === "linked" ? tx("linked") : tx("dev")}</span>
)}
{update && <Badge tone="accent">{tx("Update")}</Badge>}
{plugin.status === "failed" && (
<Badge dot tone="danger">
<T>Failed</T>
@@ -555,8 +544,6 @@ function Homepage({ value }: { value: string }) {
function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
const busy = usePlugins((s) => !!s.busy[plugin.id]);
const installing = usePlugins((s) => s.installing);
const update = useMarketUpdate(plugin);
const status = STATUS[plugin.status];
const [confirmUninstall, setConfirmUninstall] = useState(false);
@@ -659,16 +646,6 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
</div>
<div className={styles.headerActions}>
{busy && <Spinner size={14} label="Working" />}
{update && (
<Button
size="small"
variant="primary"
disabled={busy || installing}
onClick={() => void installFromMarket(update.id)}
>
{tx`Update to ${update.version}`}
</Button>
)}
<Button
size="small"
variant={plugin.enabled ? "secondary" : "primary"}
@@ -1354,19 +1354,6 @@ describe("marketplace", () => {
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["a", "b"]));
});
it("marks installed plugins with updates and updates one from its details", async () => {
setConsentPrompt(async () => ({ optional: [] }));
const consents = updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
const user = userEvent.setup();
render(<PluginsPage />);
await waitFor(() => expect(usePlugins.getState().market).toHaveLength(3));
const list = screen.getByRole("list", { name: "Installed plugins" });
expect(within(list).getAllByText("Update")).toHaveLength(2);
await user.click(within(list).getAllByRole("button", { name: /^b/i })[0]!);
await user.click(await screen.findByRole("button", { name: "Update to 2.0.0" }));
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["b"]));
});
it("announces updates with actions to install them or show the plugins", async () => {
updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
handlePluginEvent({
+6 -6
View File
@@ -214,7 +214,7 @@ export const tauriPluginsBackend: PluginsBackend = {
},
program: (id, program, scope) => call(commands.pluginProgram(id, program, scope)),
pickProgram: async () => {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: false, multiple: false, title: tx("Choose plugin executable") });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
@@ -274,7 +274,7 @@ export const tauriPluginsBackend: PluginsBackend = {
unpackPackage: (path) => call(commands.pluginUnpackPackage(path)),
rollBack: (id) => call(commands.pluginRollBack(id)),
pickPackage: async () => {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
multiple: false,
title: tx("Install Plugin from Package File"),
@@ -283,7 +283,7 @@ export const tauriPluginsBackend: PluginsBackend = {
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
pickIntellijTheme: async () => {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
multiple: false,
title: tx("Import IntelliJ Theme"),
@@ -318,14 +318,14 @@ export const tauriPluginsBackend: PluginsBackend = {
return webSchemeUrl(page.path);
},
saveWebFile: async (name, filters, bytes) => {
const { save } = await import("@/platform/dialog");
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: name, filters });
if (!path) return null;
await call(commands.pluginWebSaveFile(path, toBase64(bytes)));
return path;
},
openWebFile: async (filters) => {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ multiple: false, filters });
const path = (Array.isArray(picked) ? picked[0] : picked) ?? null;
if (!path) return null;
@@ -345,7 +345,7 @@ export const tauriPluginsBackend: PluginsBackend = {
await call(commands.pluginSetThemeFrame(color));
},
pickFolder: async () => {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
+1 -1
View File
@@ -374,7 +374,7 @@ async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] |
case "pickDirectory": {
if (signal.aborted) return null;
try {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
if (signal.aborted) return null;
const selected = await open({
directory: true,
+4 -10
View File
@@ -45,12 +45,6 @@ describe("automatic project types", () => {
expect([...useProjectTree.getState().projectTypes.values()]).toEqual(Array(9).fill("go"));
expect(backend.fsReadDir).toHaveBeenCalledTimes(9);
});
it("leaves a single project root alone", async () => {
initProjectTree("/app");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(backend.fsReadDir).not.toHaveBeenCalled();
expect(useProjectTree.getState().projectTypes.size).toBe(0);
});
it("refreshes only affected markers and clears a removed project type", async () => {
initProjectTree("/ws", "Workspace", ["/a", "/b"]);
await waitFor(() => expect(useProjectTree.getState().projectTypes.size).toBe(2));
@@ -72,9 +66,9 @@ describe("automatic project types", () => {
finish = resolve;
}),
);
initProjectTree("/old", "Old", ["/old"]);
initProjectTree("/old");
await waitFor(() => expect(finish).toBeDefined());
initProjectTree("/new", "New", ["/new"]);
initProjectTree("/new");
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/new")).toBe("node"));
finish(ok([entry("pom.xml")]));
await Promise.resolve();
@@ -91,7 +85,7 @@ describe("automatic project types", () => {
entry("README.md"),
]),
);
initProjectTree("C:/work", "Work", ["C:/work/app"]);
initProjectTree("C:/work/app");
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("C:/work/app")).toBe("gradle"));
expect(backend.fsReadDir).toHaveBeenCalledExactlyOnceWith("C:/work/app");
expect(backend.fsReadText).not.toHaveBeenCalled();
@@ -119,7 +113,7 @@ describe("automatic project types", () => {
{ ...entry("package.json", "symlink"), targetKind: "file" },
]),
);
initProjectTree("/ws", "Workspace", ["/linked"]);
initProjectTree("/linked");
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/linked")).toBe("node"));
});
});
+4 -3
View File
@@ -1,4 +1,4 @@
/** Bounded, read-only detection for workspace members, collapsed ones included; a single project root keeps its own icon. */
/** Bounded, read-only detection for content roots, including collapsed workspace members. */
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { detectProjectType, isProjectMarker } from "./projectType";
import { useProjectTree } from "./store";
@@ -16,8 +16,9 @@ export function initProjectTypes(): () => void {
let reset: (() => void) | undefined;
const load = () => {
reset?.();
const { root, members: roots } = useProjectTree.getState();
if (!root || !roots || !isTauri()) return;
const { root, members } = useProjectTree.getState();
if (!root || !isTauri()) return;
const roots = members ?? [root];
const pending = new Set(roots);
const versions = new Map<string, number>();
let active = 0;
+1 -1
View File
@@ -141,7 +141,7 @@ function TrustSettings() {
};
const addLocation = async () => {
try {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Add Trusted Location" });
const path = Array.isArray(picked) ? picked[0] : picked;
if (!path) return;
+1 -1
View File
@@ -203,7 +203,7 @@ export interface ConfigFormProps {
/** Picks files or a directory in the local file dialog, opened at `from`; empty when cancelled. */
async function choosePaths(opts: { directory?: boolean; multiple?: boolean; from: string | null }): Promise<string[]> {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
directory: !!opts.directory,
multiple: !!opts.multiple,
+1 -1
View File
@@ -72,7 +72,7 @@ function serializeEnvPairs(rows: ReadonlyArray<{ name: string; value: string }>)
async function pickLocalPath(directory: boolean, defaultPath?: string): Promise<string | null> {
if (!isTauri() || isRemoteWindow()) return null;
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory, multiple: false, defaultPath: defaultPath || undefined });
return typeof picked === "string" ? picked : Array.isArray(picked) ? (picked[0] ?? null) : null;
}
+1 -1
View File
@@ -89,7 +89,7 @@ import styles from "./FindInFiles.module.css";
/** The Directory scope's browse button: a folder chooser opened at the current directory. */
async function chooseDirectory(current: string): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
directory: true,
multiple: false,
+1 -1
View File
@@ -90,7 +90,7 @@ function PromptDialog(props: {
}
async function pickDirectory(title: string): Promise<string | null> {
const { open: openDialog } = await import("@/platform/dialog");
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
const dir = await openDialog({ multiple: false, directory: true, title });
return typeof dir === "string" ? dir : null;
}
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { PageContext } from "../components/context";
@@ -50,9 +50,7 @@ describe("Java formatting profile", () => {
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "config/style.xml" } });
mocks.open.mockResolvedValue("/project/custom.xml");
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Browse…" })));
await waitFor(() =>
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } }),
);
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } });
expect(mocks.open).toHaveBeenCalledWith(
expect.objectContaining({ filters: [{ name: "XML", extensions: ["xml"] }] }),
);
+1 -1
View File
@@ -231,7 +231,7 @@ export function KeymapPage() {
};
const importIdeaKeymap = async () => {
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const path = await open({ multiple: false, filters: [{ name: tx("IntelliJ IDEA Keymap"), extensions: ["xml"] }] });
if (!path) return;
let parsed;
+1 -1
View File
@@ -135,7 +135,7 @@ export async function choosePath(opts: {
defaultPath?: string;
}): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@/platform/dialog");
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
directory: opts.directory,
multiple: false,
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more