mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 02:31:38 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606aff1613 | ||
|
|
4d60921f24 | ||
|
|
7784f3cdef | ||
|
|
b010397711 | ||
|
|
108da537f4 | ||
|
|
c658b6e59f | ||
|
|
990a1c7a87 | ||
|
|
a58a7a0b8c | ||
|
|
c4b47da9d1 | ||
|
|
9d64134b68 | ||
|
|
ca945b13d2 | ||
|
|
97c58cc808 | ||
|
|
e158658afb | ||
|
|
db4bb52694 | ||
|
|
65bb39e08c | ||
|
|
14e4881418 | ||
|
|
55bc093fe5 | ||
|
|
37ba544733 |
No files matched your search
Generated
+39
-39
@@ -4145,7 +4145,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4169,7 +4169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4183,7 +4183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4194,7 +4194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
@@ -4212,7 +4212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4226,7 +4226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
@@ -4236,7 +4236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4253,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4267,7 +4267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4288,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
@@ -4318,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4331,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4352,7 +4352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4364,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4377,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4443,7 +4443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
@@ -4462,7 +4462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4484,7 +4484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4501,7 +4501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4557,7 +4557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4583,7 +4583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4602,7 +4602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4625,7 +4625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4648,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4661,7 +4661,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
@@ -4680,7 +4680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4704,7 +4704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4724,7 +4724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4744,7 +4744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4757,7 +4757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4778,7 +4778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4793,7 +4793,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4806,7 +4806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4819,7 +4819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4840,7 +4840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4861,7 +4861,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4885,7 +4885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4901,7 +4901,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6479,7 +6479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -178,6 +178,17 @@ 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
|
||||
|
||||
@@ -135,6 +135,36 @@ 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`.
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+122
-5
@@ -10,6 +10,7 @@
|
||||
mod acp;
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod external;
|
||||
pub mod mcp;
|
||||
mod model;
|
||||
mod native;
|
||||
@@ -19,6 +20,7 @@ mod skills;
|
||||
mod timeline;
|
||||
mod worktree;
|
||||
|
||||
pub use external::ExternalSession;
|
||||
pub use model::*;
|
||||
|
||||
use conversation::{Conversation, SavedSubagent, Session};
|
||||
@@ -585,6 +587,91 @@ 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
|
||||
@@ -1623,6 +1710,30 @@ 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()
|
||||
@@ -1665,12 +1776,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 loaded: Result<Value, RpcError> = peer
|
||||
.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers }), None)
|
||||
.await;
|
||||
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;
|
||||
tokio::time::sleep(REPLAY_GRACE).await;
|
||||
self.replaying.store(false, Ordering::Release);
|
||||
match loaded {
|
||||
@@ -1682,8 +1793,9 @@ impl Task {
|
||||
let (session_id, result) = match session {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let v: Value = peer
|
||||
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": mcp_servers }), None)
|
||||
let params = json!({ "cwd": &cwd_s, "mcpServers": &mcp_servers });
|
||||
let v = self
|
||||
.request_with_auth(&peer, &init, &mut tried_auth, "session/new", params)
|
||||
.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();
|
||||
@@ -2429,6 +2541,11 @@ 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())?;
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Cli for Process {
|
||||
}
|
||||
|
||||
/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`).
|
||||
pub(super) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
|
||||
pub(crate) 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(super) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
|
||||
pub(crate) 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(super) fn is_turn_start(e: &Value) -> bool {
|
||||
pub(crate) 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(super) fn title(entries: &[Value]) -> Option<String> {
|
||||
pub(crate) fn title(entries: &[Value]) -> Option<String> {
|
||||
entries
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
//! <id>.json`): loading that session, from this connection or a fresh one, starts it that
|
||||
//! way.
|
||||
|
||||
mod cli;
|
||||
mod history;
|
||||
pub(crate) mod cli;
|
||||
pub(crate) mod history;
|
||||
mod settings;
|
||||
mod tools;
|
||||
mod translate;
|
||||
|
||||
@@ -35,6 +35,9 @@ 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.
|
||||
@@ -127,9 +130,9 @@ impl Handler for FakeAgent {
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
if (method == "session/new" && self.auth_on_new)
|
||||
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
|
||||
{
|
||||
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"))) {
|
||||
return Err(self
|
||||
.request_error
|
||||
.clone()
|
||||
@@ -176,6 +179,16 @@ 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() {
|
||||
@@ -696,6 +709,91 @@ 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();
|
||||
|
||||
@@ -322,6 +322,7 @@ 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,
|
||||
@@ -354,6 +355,7 @@ 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(),
|
||||
|
||||
@@ -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,
|
||||
McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -181,6 +181,17 @@ 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)?;
|
||||
@@ -398,6 +409,10 @@ 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
|
||||
});
|
||||
|
||||
@@ -130,6 +130,17 @@ 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> {
|
||||
@@ -332,6 +343,7 @@ 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
|
||||
});
|
||||
|
||||
@@ -27,6 +27,9 @@ 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");
|
||||
@@ -56,7 +59,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: Duration::from_millis(250), ..Default::default() },
|
||||
Limits { timeout, ..Default::default() },
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
@@ -202,7 +205,8 @@ 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() {
|
||||
let f = Harness::new();
|
||||
// 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));
|
||||
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"] {
|
||||
|
||||
@@ -157,3 +157,22 @@ 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:?}");
|
||||
}
|
||||
@@ -11,10 +11,29 @@ 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>,
|
||||
@@ -48,13 +67,14 @@ impl ClientHandler {
|
||||
other => other.to_string(),
|
||||
};
|
||||
let value = ¶ms["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 value.get("kind").and_then(Value::as_str) {
|
||||
match kind {
|
||||
Some("begin") => {
|
||||
let title = value
|
||||
.get("title")
|
||||
@@ -69,7 +89,18 @@ impl ClientHandler {
|
||||
// (PHPantom: begin, begin, end, end). The task it replaces ends now, or no
|
||||
// `end` would ever reach it.
|
||||
replaced = progress
|
||||
.insert(token, Progress { task, title: title.clone(), message: message.clone(), cancel })
|
||||
.insert(
|
||||
token.clone(),
|
||||
Progress {
|
||||
task,
|
||||
title: title.clone(),
|
||||
message: message.clone(),
|
||||
cancel,
|
||||
last: tokio::time::Instant::now(),
|
||||
stalled: false,
|
||||
fraction,
|
||||
},
|
||||
)
|
||||
.map(|p| IdeEvent::Task {
|
||||
id: p.task,
|
||||
title: p.title,
|
||||
@@ -82,9 +113,14 @@ 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, ¶ms["token"]))
|
||||
@@ -123,9 +159,64 @@ 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()) {
|
||||
|
||||
@@ -74,6 +74,22 @@ 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 {
|
||||
@@ -542,9 +558,10 @@ impl Server {
|
||||
self.publish(sink);
|
||||
}
|
||||
|
||||
/// Whether the server reports work in progress (indexing, analysis).
|
||||
/// Whether the server reports work in progress (indexing, analysis). A task marked
|
||||
/// stuck stays visible but no longer holds the server busy.
|
||||
pub fn is_busy(&self) -> bool {
|
||||
!self.progress.lock().is_empty() || *self.loading.lock() == Some(true)
|
||||
self.progress.lock().values().any(|p| !p.stalled) || *self.loading.lock() == Some(true)
|
||||
}
|
||||
|
||||
/// Whether the server says it is still loading the project, so an empty
|
||||
@@ -564,6 +581,16 @@ 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();
|
||||
@@ -830,7 +857,15 @@ 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 },
|
||||
Progress {
|
||||
task: TaskId::next(),
|
||||
title: "Old work".into(),
|
||||
message: None,
|
||||
cancel: None,
|
||||
last: tokio::time::Instant::now(),
|
||||
fraction: None,
|
||||
stalled: false,
|
||||
},
|
||||
);
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let manager = Arc::new(Mutex::new(std::sync::Weak::<crate::LspManager>::new()));
|
||||
@@ -869,6 +904,49 @@ 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();
|
||||
|
||||
@@ -1685,6 +1685,59 @@ 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.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Deployment over SFTP or local folders, and SSH run targets.
|
||||
//! Shared by IPC commands and project save/delete hooks.
|
||||
|
||||
use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport};
|
||||
use crate::mapping::{Excludes, Mappings, ResolvedMapping};
|
||||
use crate::target::{RemoteRuns, RemoteStart, SyncFn};
|
||||
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 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};
|
||||
@@ -359,10 +360,29 @@ 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() };
|
||||
@@ -377,6 +397,7 @@ 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 {
|
||||
@@ -387,7 +408,17 @@ impl RemoteService {
|
||||
ide_core::error::ErrorKind::Io,
|
||||
format!("upload of {} failed: {}", e.path, e.message),
|
||||
)),
|
||||
None => Ok(()),
|
||||
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(())
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
@@ -396,10 +427,58 @@ 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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -118,6 +118,105 @@ 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,
|
||||
@@ -381,4 +480,51 @@ 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/*");
|
||||
}
|
||||
}
|
||||
@@ -59,3 +59,38 @@ 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
@@ -2,7 +2,7 @@
|
||||
"name": "openidea",
|
||||
"private": true,
|
||||
"description": "An open-source IDE inspired by IntelliJ IDEA",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
|
||||
interface endpoints-provider {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
@@ -32,6 +32,9 @@ 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.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
@@ -73,3 +73,22 @@ 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>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
/// Writes to the IDE log, tagged with the plugin id.
|
||||
interface log {
|
||||
@@ -9,6 +9,7 @@ interface log {
|
||||
|
||||
interface ui {
|
||||
use types.{error, notification-level};
|
||||
use workspace.{project};
|
||||
|
||||
record status-item {
|
||||
text: string,
|
||||
@@ -50,6 +51,12 @@ 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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
world plugin {
|
||||
import log;
|
||||
@@ -37,6 +37,7 @@ world plugin {
|
||||
import database;
|
||||
import mcp;
|
||||
import settings;
|
||||
import extensions;
|
||||
|
||||
export lifecycle;
|
||||
export commands;
|
||||
@@ -200,3 +201,8 @@ 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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
/// Project-scoped targets owned by the registering plugin (API 1.4).
|
||||
interface run-targets {
|
||||
@@ -16,10 +16,7 @@ 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 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.
|
||||
/// Registration never selects the target. Debugging plugin targets is invalid.
|
||||
record run-target {
|
||||
id: string,
|
||||
name: string,
|
||||
@@ -34,6 +31,29 @@ 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.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
/// Launches this plugin's configurations in the invoking window's Run tool window.
|
||||
interface run {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.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.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
/// Declarative views: tool windows declared
|
||||
/// under `[[contributes.views]]` and custom editors under
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.4.0;
|
||||
package openidea:plugin@1.5.0;
|
||||
|
||||
interface workspace {
|
||||
use types.{error, location};
|
||||
@@ -66,6 +66,12 @@ 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
|
||||
|
||||
@@ -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,
|
||||
McpServer, McpServerInput, NewAgentTask,
|
||||
ExternalSession, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath, RequestId};
|
||||
use ide_host::ops::agent;
|
||||
@@ -166,6 +166,25 @@ 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]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! 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<>k::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<>k::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]);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -66,6 +67,8 @@ 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,
|
||||
@@ -128,6 +131,8 @@ 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,
|
||||
@@ -380,6 +385,7 @@ 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,
|
||||
|
||||
@@ -179,6 +179,17 @@ 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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenIDEA",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"identifier": "ai.l0ng.openidea",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -8,6 +8,13 @@ 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")),
|
||||
@@ -162,6 +169,10 @@ 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). */
|
||||
@@ -733,6 +744,8 @@ 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 })),
|
||||
@@ -4297,6 +4310,19 @@ 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,
|
||||
@@ -4875,6 +4901,21 @@ 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,
|
||||
@@ -8809,6 +8850,7 @@ 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,
|
||||
|
||||
@@ -26,7 +26,7 @@ async function retrieve(path: string, choose: boolean) {
|
||||
try {
|
||||
let sources: string | null = null;
|
||||
if (choose) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const selected = await open({
|
||||
title: tx("Choose Sources…"),
|
||||
filters: [{ name: tx("Source archives"), extensions: ["jar", "zip"] }],
|
||||
|
||||
@@ -187,3 +187,15 @@
|
||||
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;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
sessionKey,
|
||||
type TaskGroup,
|
||||
} from "./model";
|
||||
import { openHome, useAgents } from "./store";
|
||||
import { importExternalSession, openHome, refreshExternalSessions, useAgents } from "./store";
|
||||
import { taskMenu } from "./taskActions";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
@@ -93,6 +93,19 @@ 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?.());
|
||||
@@ -285,6 +298,37 @@ 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>
|
||||
|
||||
@@ -54,10 +54,6 @@
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.statusActions > .changesToggle {
|
||||
height: var(--h-control-small);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.statusText {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
@@ -471,6 +467,31 @@
|
||||
.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);
|
||||
@@ -521,7 +542,9 @@
|
||||
}
|
||||
.markdown :global(li:has(> input[type="checkbox"])) {
|
||||
margin-left: -20px;
|
||||
list-style: none;
|
||||
}
|
||||
.markdown :global(li:has(> input[type="checkbox"]))::before {
|
||||
content: none;
|
||||
}
|
||||
.markdown > :global(:first-child) {
|
||||
margin-top: 0;
|
||||
@@ -1230,33 +1253,6 @@
|
||||
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;
|
||||
@@ -1546,12 +1542,6 @@
|
||||
.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;
|
||||
|
||||
@@ -75,7 +75,7 @@ import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { ImagePreview, imageSrc } from "./ImagePreview";
|
||||
import { useAgentSettings } from "./agentSettings";
|
||||
import { currentTool, foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
|
||||
import { foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
|
||||
import { useRevealedText } from "./reveal";
|
||||
import { AgentReply, MessageActions, ReplyActions } from "./AgentReply";
|
||||
import {
|
||||
@@ -272,20 +272,8 @@ function useLocalHead(status: AgentTask["status"] | null, root: string | null):
|
||||
return local ? head : null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** The task's terminal, at the end of its status line. */
|
||||
function StatusActions({ task }: { task: AgentTask }) {
|
||||
return (
|
||||
<span className={styles.statusActions}>
|
||||
<IconButton
|
||||
@@ -294,18 +282,6 @@ function StatusActions({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -315,16 +291,10 @@ 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);
|
||||
@@ -357,7 +327,7 @@ function SessionStatus({
|
||||
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
|
||||
</span>
|
||||
<Meters task={task} entries={entries} />
|
||||
<StatusActions task={task} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges} />
|
||||
<StatusActions task={task} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2388,7 +2358,6 @@ 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}>
|
||||
@@ -2445,10 +2414,6 @@ 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>
|
||||
)}
|
||||
@@ -2559,14 +2524,7 @@ function SessionComposer({
|
||||
);
|
||||
return (
|
||||
<div className={styles.composerArea}>
|
||||
<SessionStatus
|
||||
task={task}
|
||||
entries={entries}
|
||||
head={head}
|
||||
changes={changes}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={onToggleChanges}
|
||||
/>
|
||||
<SessionStatus task={task} entries={entries} head={head} />
|
||||
{task.status === "archived" && (
|
||||
<div className={styles.banner}>
|
||||
<span className={styles.bannerText}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Agents of the ACP Registry: listing, install / update / remove (`agent_registry_*`). */
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { open } from "@/platform/dialog";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
AgentProvider,
|
||||
|
||||
@@ -22,6 +22,15 @@ 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")>()),
|
||||
@@ -40,6 +49,16 @@ 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", () => ({
|
||||
@@ -118,6 +137,9 @@ 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" }]],
|
||||
@@ -129,6 +151,8 @@ beforeEach(() => {
|
||||
homeOpen: false,
|
||||
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
|
||||
entries: new Map(backend.entries),
|
||||
external: [],
|
||||
externalError: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1124,14 +1148,14 @@ describe("session state across tasks and hosts", () => {
|
||||
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
|
||||
render(<AgentsView />);
|
||||
await screen.findByText("Answer A");
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Show changes" })).toBeEnabled());
|
||||
await screen.findByRole("button", { name: /1 changed file/ });
|
||||
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: "Show changes" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 changed file/ }));
|
||||
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
|
||||
expect(screen.getByText("Answer A")).not.toBeVisible();
|
||||
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
|
||||
@@ -1256,3 +1280,36 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AgentLimit,
|
||||
AgentProvider,
|
||||
AgentTask,
|
||||
ExternalSession,
|
||||
Isolation,
|
||||
} from "@/bindings";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -77,6 +78,10 @@ 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. */
|
||||
@@ -138,6 +143,8 @@ 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",
|
||||
@@ -715,6 +722,26 @@ 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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentEntry } from "@/bindings";
|
||||
import { currentTool, foldedTurns, summarizeWork, workParts } from "./timeline";
|
||||
import { foldedTurns, summarizeWork, workParts } from "./timeline";
|
||||
|
||||
const user = (text: string, extra: Partial<Extract<AgentEntry, { type: "user" }>> = {}): AgentEntry => ({
|
||||
type: "user",
|
||||
@@ -45,11 +45,6 @@ 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", () => {
|
||||
|
||||
@@ -91,15 +91,6 @@ 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;
|
||||
|
||||
@@ -40,7 +40,7 @@ export function AnalysisDialogHost() {
|
||||
|
||||
async function browseDirectory(initial: string): Promise<string | null> {
|
||||
if (!isTauri()) return null;
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({ directory: true, multiple: false, defaultPath: initial || undefined });
|
||||
return typeof picked === "string" ? picked : null;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ function NewProjectDialog({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const browse = async () => {
|
||||
if (!isTauri()) return;
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
|
||||
@@ -55,7 +55,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
|
||||
setAdding("");
|
||||
};
|
||||
const browse = async (target: "workspace" | "project") => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const result = await open({
|
||||
directory: true,
|
||||
multiple: target === "project",
|
||||
|
||||
@@ -56,7 +56,7 @@ function errorText(e: unknown) {
|
||||
}
|
||||
|
||||
async function exportSettings() {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { save } = await import("@/platform/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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({
|
||||
title: "Import Settings",
|
||||
multiple: false,
|
||||
|
||||
@@ -208,14 +208,14 @@ export function defaultProjectDirectory(): string | undefined {
|
||||
}
|
||||
|
||||
async function pickFolder(title: string): Promise<string | null> {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({ directory: false, multiple: true, title, defaultPath: defaultProjectDirectory() });
|
||||
return picked === null ? [] : Array.isArray(picked) ? picked : [picked];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } 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";
|
||||
@@ -20,6 +21,7 @@ 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(),
|
||||
@@ -52,6 +54,7 @@ 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 }));
|
||||
@@ -230,8 +233,12 @@ 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" }));
|
||||
});
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ defaultPath: "/p", directory: false }));
|
||||
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["b/pom.xml"]');
|
||||
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"]'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/p/a/pom.xml"])(
|
||||
@@ -243,6 +250,43 @@ 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"] } } },
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 {
|
||||
@@ -294,22 +295,46 @@ export async function unlinkProjects(pom: string | null) {
|
||||
await setLinked(root, [pom], false);
|
||||
}
|
||||
|
||||
/** 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 [];
|
||||
/** 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;
|
||||
}
|
||||
const pom = path.slice(base.length + 1);
|
||||
const parts = pom.split("/");
|
||||
return parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml" ? [] : [pom];
|
||||
});
|
||||
if (parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml") outside.push(p);
|
||||
else inside.push(pom);
|
||||
}
|
||||
return { inside, outside };
|
||||
}
|
||||
|
||||
export async function addProjects() {
|
||||
const root = useProject.getState().project?.root;
|
||||
if (!root || useProject.getState().project?.workspace || !isTauri()) return;
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({
|
||||
title: tx`Add Maven Projects`,
|
||||
directory: false,
|
||||
@@ -317,8 +342,19 @@ export async function addProjects() {
|
||||
defaultPath: root,
|
||||
filters: [{ name: "pom.xml", extensions: ["xml"] }],
|
||||
});
|
||||
const poms = projectPoms(root, typeof picked === "string" ? [picked] : (picked ?? []));
|
||||
const paths = typeof picked === "string" ? [picked] : (picked ?? []);
|
||||
if (!paths.length) return;
|
||||
const { inside: poms, outside } = projectPoms(root, paths);
|
||||
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. */
|
||||
@@ -332,7 +368,7 @@ export async function linkGradleProjects() {
|
||||
!isTauri()
|
||||
)
|
||||
return;
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({
|
||||
title: tx`Link Gradle Projects`,
|
||||
directory: false,
|
||||
|
||||
@@ -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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const file = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
|
||||
@@ -149,7 +149,7 @@ export function ImportDialog({ id, schema, table }: { id: string; schema: string
|
||||
|
||||
const choose = async () => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const f = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
|
||||
@@ -319,11 +319,22 @@ export function DatabaseView() {
|
||||
const load = async (n: Node) => {
|
||||
if (n.kind === "source") {
|
||||
const id = n.source.id ?? "";
|
||||
if (!(await connect(id))) return;
|
||||
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 (listsDatabases(n.source)) await loadDatabases(id);
|
||||
else await loadSchemas(id);
|
||||
} else if (n.kind === "database") {
|
||||
if (await connect(n.id)) await loadSchemas(n.id);
|
||||
if (!(await connect(n.id))) {
|
||||
useDatabase.setState((s) => ({
|
||||
schemas: { ...s.schemas, [n.id]: { status: "error", message: tx("Cannot connect") } },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ export function ExportDialog({
|
||||
|
||||
const browse = async () => {
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { save } = await import("@/platform/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);
|
||||
|
||||
@@ -175,7 +175,7 @@ function blank(): DataSource {
|
||||
}
|
||||
|
||||
async function pickFile(title: string): Promise<string | null> {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const file = await open({ multiple: false, directory: false, title });
|
||||
return typeof file === "string" ? file : null;
|
||||
}
|
||||
@@ -384,6 +384,8 @@ 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;
|
||||
@@ -487,7 +489,7 @@ export function SourceDialog({
|
||||
const loadNames = async () => {
|
||||
const id = initial?.id;
|
||||
if (!id) return;
|
||||
await Promise.all([postgres ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]);
|
||||
await Promise.all([serverDatabases ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]);
|
||||
};
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
@@ -702,7 +704,7 @@ export function SourceDialog({
|
||||
fallback={300}
|
||||
onChange={(autoDisconnectSecs) => setOptions({ autoDisconnectSecs })}
|
||||
/>
|
||||
{postgres && (
|
||||
{serverDatabases && (
|
||||
<>
|
||||
<span />
|
||||
<Checkbox
|
||||
@@ -927,7 +929,7 @@ export function SourceDialog({
|
||||
const schemasTab = (
|
||||
<>
|
||||
<div className={styles.formWide}>
|
||||
{postgres && (
|
||||
{serverDatabases && (
|
||||
<NamePicker
|
||||
allLabel={tx("All databases")}
|
||||
addLabel={tx("Add databases pattern")}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
toCsv,
|
||||
toTsv,
|
||||
} from "./model";
|
||||
import { listsDatabases } from "./store";
|
||||
import { VENDORS, sourceUrl, vendorById, vendorOf, vendorPreset } from "./vendors";
|
||||
|
||||
describe("clipboard formats", () => {
|
||||
@@ -160,6 +161,15 @@ 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", () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ interface InTable {
|
||||
|
||||
export type DbNode =
|
||||
| { kind: "source"; key: string; source: DataSource }
|
||||
/** A database of a PostgreSQL server; `id` is its connection target. */
|
||||
/** A database of a PostgreSQL / SQL Server host; `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 }
|
||||
|
||||
@@ -319,8 +319,13 @@ 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));
|
||||
forget(id);
|
||||
// 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);
|
||||
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). */
|
||||
@@ -497,9 +502,9 @@ export function consoleTints(s: Pick<DatabaseState, "consoles" | "sources">): Ma
|
||||
return out;
|
||||
}
|
||||
|
||||
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
|
||||
/** PostgreSQL / SQL Server 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.options?.singleDatabase;
|
||||
return (source?.driver === "postgres" || source?.driver === "sqlServer") && !source.options?.singleDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const file = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ const act = (
|
||||
const sep: MenuItem = { kind: "separator" };
|
||||
|
||||
async function sqliteFromFile() {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/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) });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { debugUnsupportedReason, launchConfigFor } from "../launch";
|
||||
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 } };
|
||||
});
|
||||
|
||||
const rc = (p: Partial<RunConfig>): RunConfig => ({
|
||||
id: "x",
|
||||
@@ -20,7 +31,10 @@ 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" }));
|
||||
beforeEach(() => {
|
||||
usePlatform.setState({ os: "linux" });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => usePlatform.setState(originalPlatform));
|
||||
|
||||
describe("run config → launch config", () => {
|
||||
@@ -494,3 +508,18 @@ 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" });
|
||||
});
|
||||
});
|
||||
@@ -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 } from "./launch";
|
||||
import { debugUnsupportedReason, launchConfigFor, withEnvFiles } 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(prepared, root, php);
|
||||
const launch = launchConfigFor(await withEnvFiles(prepared, root), 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(config, root);
|
||||
const launch = launchConfigFor(await withEnvFiles(config, root), root);
|
||||
if (!launch) throw new Error("This configuration has no debugger");
|
||||
let id: number | null = null;
|
||||
let stopped = false;
|
||||
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -111,6 +112,7 @@ 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,7 +1,7 @@
|
||||
import { parseArgs, formatArgs } from "@/platform/commandLine";
|
||||
/** Run configuration → DAP launch configuration, per adapter. */
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import type { LaunchConfig, RunConfig } from "@/platform/ipc";
|
||||
import { call, commands, type LaunchConfig, type RunConfig } from "@/platform/ipc";
|
||||
import { hostOs } from "@/platform/remote";
|
||||
|
||||
function isAbsolute(p: string) {
|
||||
@@ -37,6 +37,18 @@ 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;
|
||||
|
||||
@@ -129,6 +129,15 @@ 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>
|
||||
@@ -148,6 +157,7 @@ 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
|
||||
@@ -166,6 +176,7 @@ 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>
|
||||
@@ -544,6 +555,8 @@ 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);
|
||||
|
||||
@@ -646,6 +659,16 @@ 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,6 +1354,19 @@ 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({
|
||||
|
||||
@@ -214,7 +214,7 @@ export const tauriPluginsBackend: PluginsBackend = {
|
||||
},
|
||||
program: (id, program, scope) => call(commands.pluginProgram(id, program, scope)),
|
||||
pickProgram: async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/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("@tauri-apps/plugin-dialog");
|
||||
const { save } = await import("@/platform/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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
|
||||
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
|
||||
},
|
||||
|
||||
@@ -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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
if (signal.aborted) return null;
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
|
||||
@@ -45,6 +45,12 @@ 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));
|
||||
@@ -66,9 +72,9 @@ describe("automatic project types", () => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
initProjectTree("/old");
|
||||
initProjectTree("/old", "Old", ["/old"]);
|
||||
await waitFor(() => expect(finish).toBeDefined());
|
||||
initProjectTree("/new");
|
||||
initProjectTree("/new", "New", ["/new"]);
|
||||
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/new")).toBe("node"));
|
||||
finish(ok([entry("pom.xml")]));
|
||||
await Promise.resolve();
|
||||
@@ -85,7 +91,7 @@ describe("automatic project types", () => {
|
||||
entry("README.md"),
|
||||
]),
|
||||
);
|
||||
initProjectTree("C:/work/app");
|
||||
initProjectTree("C:/work", "Work", ["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();
|
||||
@@ -113,7 +119,7 @@ describe("automatic project types", () => {
|
||||
{ ...entry("package.json", "symlink"), targetKind: "file" },
|
||||
]),
|
||||
);
|
||||
initProjectTree("/linked");
|
||||
initProjectTree("/ws", "Workspace", ["/linked"]);
|
||||
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/linked")).toBe("node"));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Bounded, read-only detection for content roots, including collapsed workspace members. */
|
||||
/** Bounded, read-only detection for workspace members, collapsed ones included; a single project root keeps its own icon. */
|
||||
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { detectProjectType, isProjectMarker } from "./projectType";
|
||||
import { useProjectTree } from "./store";
|
||||
@@ -16,9 +16,8 @@ export function initProjectTypes(): () => void {
|
||||
let reset: (() => void) | undefined;
|
||||
const load = () => {
|
||||
reset?.();
|
||||
const { root, members } = useProjectTree.getState();
|
||||
if (!root || !isTauri()) return;
|
||||
const roots = members ?? [root];
|
||||
const { root, members: roots } = useProjectTree.getState();
|
||||
if (!root || !roots || !isTauri()) return;
|
||||
const pending = new Set(roots);
|
||||
const versions = new Map<string, number>();
|
||||
let active = 0;
|
||||
|
||||
@@ -141,7 +141,7 @@ function TrustSettings() {
|
||||
};
|
||||
const addLocation = async () => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({ directory: true, multiple: false, title: "Add Trusted Location" });
|
||||
const path = Array.isArray(picked) ? picked[0] : picked;
|
||||
if (!path) return;
|
||||
|
||||
@@ -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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({
|
||||
directory: !!opts.directory,
|
||||
multiple: !!opts.multiple,
|
||||
|
||||
@@ -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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({ directory, multiple: false, defaultPath: defaultPath || undefined });
|
||||
return typeof picked === "string" ? picked : Array.isArray(picked) ? (picked[0] ?? null) : null;
|
||||
}
|
||||
|
||||
@@ -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("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
|
||||
@@ -90,7 +90,7 @@ function PromptDialog(props: {
|
||||
}
|
||||
|
||||
async function pickDirectory(title: string): Promise<string | null> {
|
||||
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open: openDialog } = await import("@/platform/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 } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { PageContext } from "../components/context";
|
||||
@@ -50,7 +50,9 @@ 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…" })));
|
||||
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } });
|
||||
await waitFor(() =>
|
||||
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } }),
|
||||
);
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filters: [{ name: "XML", extensions: ["xml"] }] }),
|
||||
);
|
||||
|
||||
@@ -231,7 +231,7 @@ export function KeymapPage() {
|
||||
};
|
||||
|
||||
const importIdeaKeymap = async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const path = await open({ multiple: false, filters: [{ name: tx("IntelliJ IDEA Keymap"), extensions: ["xml"] }] });
|
||||
if (!path) return;
|
||||
let parsed;
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function choosePath(opts: {
|
||||
defaultPath?: string;
|
||||
}): Promise<string | null> {
|
||||
if (!isTauri()) return null;
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { open } = await import("@/platform/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
Reference in new issue
Block a user