Compare commits

...
Author SHA1 Message Date
arookieofc 606aff1613 fix(remote): sync outside-root classpath entries for SSH Java launches (#2641) 2026-11-11 14:58:49 +08:00
Gabriel Gondim 4d60921f24 feat(database): list all databases on a SQL Server host (#2629)
SQL Server data sources without single-database mode now show a database level in the tree, like PostgreSQL: every online database the login can access, each expanding to its schemas, with the Schemas tab database picker and the Single database mode option. The backend already listed them; listsDatabases() only enabled the level for Postgres. A failed connect shows an error under the node instead of leaving it on Loading, and saving Properties reloads what the tree had loaded for that source so expanded nodes don't stay on Loading.

Closes #2600

Co-authored-by: GGVidal <[email protected]>
2026-11-11 14:40:52 +08:00
Paulem 7784f3cdef fix(dialog): fall back to a GTK file chooser on Linux when no portal or zenity is available (#2394)
On Linux sessions with neither an xdg-desktop-portal nor zenity (antiX), rfd's xdg-portal backend returned nothing and every Open / Save picker silently did nothing. All pickers now go through src/platform/dialog.ts; on Linux the backend checks once for a reachable portal or zenity and otherwise shows a GTK FileChooserDialog. Other platforms and sessions keep the desktop's dialog. dialog.test.ts keeps every picker (including Move Worktree, keymap import, Mermaid export and library sources) on the wrapper.

Fixes #2386

Co-authored-by: Paulem79 <[email protected]>
2026-11-11 14:25:03 +08:00
misswellandmisswell b010397711 fix(lsp): mark progress tasks that stop reporting (#757)
A $/progress task that reports nothing for 3 minutes (jdtls's Maven import waiting on a repository that never answers) kept the server Busy forever, which also held wait_idle and ReferencesIncomplete::Indexing. Such a task now stays open with its last message and fraction, marked "no progress for 180 s", and no longer counts as busy; a late report clears the mark and the same task finishes normally.

Co-authored-by: misswell <[email protected]>
2026-11-11 14:18:16 +08:00
arookieofc 108da537f4 feat(agents): list and import sessions started outside OpenIDEA (#2640)
* feat(agents): list and import sessions started outside OpenIDEA

Sessions started in a terminal, another editor or the CLIs never showed
up in OpenIDEA, so users had to re-explain their work (#312). The agent
history popup now lists them under Sessions from other apps: Claude Code
transcripts and Codex rollouts on this machine, with title, working
directory and age. Importing one creates a task whose first turn loads
and replays the transcript through the existing session/load path.

* i18n: add zh translation for 'Sessions from other apps'
2026-11-11 12:42:01 +08:00
Deng Liuanddengliu c658b6e59f feat(terminal): highlight visible occurrences of selected text (#2638)
Selecting text in the terminal highlights its other occurrences in the visible rows, like the editor's identifier-under-caret highlight. Only the viewport (plus a few rows of margin) is scanned, matches are cached per logical line and drawn in one overlay layer, recomputation is coalesced to one pass per animation frame, and highlights are cleared during heavy output and redrawn once it settles. Wrapped lines and wide characters map to the right cells; multi-line, very short or whitespace-only selections, the alternate buffer and Find-bar selections are skipped. Settings › Tools › Terminal › Highlight occurrences of selected text turns it off.

Co-authored-by: dengliu <[email protected]>
2026-11-11 12:41:33 +08:00
Deng Liuanddengliu 990a1c7a87 feat(agent): retry session setup after ACP authenticate on auth error (#2637)
When session/load or session/new fails with an authentication error and the agent advertises a non-terminal `agent` authentication method in `initialize`, the IDE now calls ACP `authenticate` with that method once per session setup and retries the failed call before falling back to the sign-in error. Terminal-login methods (`_meta.terminal-auth`) keep using the existing sign-in guidance.

Co-authored-by: dengliu <[email protected]>
2026-11-11 12:37:34 +08:00
l0ng-ai a58a7a0b8c fix(project): keep the project root icon outside workspaces (#2657)
Project type detection only runs for workspace members. A single project
root no longer takes the icon of its first matching build marker, which
labelled a Cargo + Vite repository as a Vite project.
2026-11-11 12:36:12 +08:00
arookieofc c4b47da9d1 fix(maven): link Windows path variants and warn on outside-root picks (#2639)
* fix(maven): link Windows path variants and warn on outside-root picks

The Add Maven Projects dialog hands back whatever form Windows uses:
drive-letter case may differ from the stored root and long paths come
back \?\ -prefixed, so in-root picks were silently dropped and the
button looked dead (#2626). Normalize both sides before comparing
(case-insensitively on Windows) and notify which picks were skipped
because Maven only links POMs inside the open project.

* i18n: tag Maven outside-root notice with tx and add zh entries
2026-11-11 12:33:15 +08:00
l0ng-ai 9d64134b68 fix(agents): keep list selection unbroken in agent replies (#2655)
Draw list markers as non-selectable pseudo-elements instead of native
markers. WebKit paints a selection box around each native marker, which
left a broken stripe down the list gutter when a reply was selected.
2026-11-11 12:32:27 +08:00
l0ng-ai ca945b13d2 feat(plugins): show available updates on the Installed tab (#2654)
Installed plugins with a newer, loadable marketplace version get an Update
badge in the list and an "Update to x.y.z" button in their details, which
installs it from the marketplace.
2026-11-11 12:32:22 +08:00
l0ng-ai 97c58cc808 fix(agents): keep a conversation drag-selection out of the composer (#2653)
Dragging a selection from the last message down past the conversation
highlighted the composer's text field. During such a drag everything outside
the conversation is made unselectable, but WebKit keeps editable text
selectable regardless of user-select, so the selection reached the textarea.
Text fields outside the selection scope now also drop out of hit testing while
the drag lasts.
2026-11-11 12:32:17 +08:00
l0ng-ai e158658afb fix(agents): drop the status bar's Changes toggle (#2652)
The session status bar ended with a Changes toggle that did the same as the
"N changed files" chip right below it. The chip opens and closes the Changes
pane and also shows the line counts, the pane has its own hide button, and the
status-bar toggle was disabled whenever there were no changes, so the status
bar now keeps only the Terminal button. The unused "Show changes" string goes
from the locale catalogs.
2026-11-11 12:32:12 +08:00
l0ng-ai db4bb52694 fix(agents): drop the running tool's name from the working row (#2645)
The tool in progress already shows as the last card of the conversation, and
next to it the row's elapsed time read as that tool's duration rather than the
turn's. The row now shows Working… with the turn's elapsed time, and still
shows retries and the starting state.
2026-11-11 12:31:37 +08:00
l0ng-ai 65bb39e08c test(plugins): give the official examples test a longer command budget (#2643)
The plugin_data_tools harness caps plugin commands at 250 ms to test interruption; the official examples' first command compiles and scans the project and ran over that on Windows runners. That test now uses a 10 s budget.
2026-11-11 12:08:25 +08:00
arookieofc 14e4881418 fix(debug): load run configuration .env files for debug launches (#2633)
Debug launches only copied the explicit env map into the DAP launch
arguments and dropped the configuration's .env files, which Run loads
via ide_run::envfile. Add a run_env_files command that loads the files
with the same loader over the system environment, and merge the result
under the explicit variables when building debug launches (generic,
plugin-hosted and java-debug paths); explicit variables still win and a
missing file fails the launch with the same error Run reports.

Fixes #753 (comment): .env applied in Run but ignored in Debug.
2026-11-11 10:41:16 +08:00
l0ng-ai 55bc093fe5 chore: v0.1.41 2026-11-11 09:51:47 +08:00
l0ng-ai 37ba544733 fix(run): run JUnit configurations with the build's JDK (#2627)
JUnit configurations in Maven and Gradle projects ran their build command without the project JDK, so with no JAVA_HOME the build failed with "Unable to locate a Java Runtime". apply_to_run_configs now gives junit configurations the Maven runner or Gradle build environment (JDK, MAVEN_OPTS, settings) without changing the command line, and ide-host applies it to junit configurations.
2026-11-11 09:42:19 +08:00
132 changed files with 2812 additions and 302 deletions

No files matched your search

Generated
+39 -39
View File
@@ -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
View File
@@ -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"
+11
View File
@@ -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
+30
View File
@@ -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`.
+270
View File
@@ -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
View File
@@ -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())?;
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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;
+101 -3
View File
@@ -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();
+2
View File
@@ -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(),
+16 -1
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
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
});
+1 -1
View File
@@ -337,7 +337,7 @@ async fn start_inner(
// what makes the setting take effect without reopening the project.
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
} else if matches!(config.kind.as_str(), "gradle" | "tomcat" | "junit") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
} else {
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
+12
View File
@@ -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
});
+6 -2
View File
@@ -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"] {
+19
View File
@@ -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:?}");
}
+93 -2
View File
@@ -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 = &params["value"];
let kind = value.get("kind").and_then(Value::as_str);
let message = value.get("message").and_then(Value::as_str).map(str::to_owned);
let fraction = value.get("percentage").and_then(Value::as_f64).map(|p| (p / 100.0).clamp(0.0, 1.0) as f32);
let cancellable = value.get("cancellable").and_then(Value::as_bool);
let mut replaced = None;
let event = {
let mut progress = self.server.progress.lock();
match 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, &params["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()) {
+81 -3
View File
@@ -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();
+53
View File
@@ -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.
+84 -5
View File
@@ -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()
}
+147 -1
View File
@@ -10,7 +10,7 @@ use ide_ssh::{Output, RemoteProcess, Session, Sig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
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/*");
}
}
+18
View File
@@ -1561,6 +1561,24 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned());
}
}
// A JUnit configuration runs the module's own Maven or Gradle command line: it needs
// that build's environment (its JDK), not another launcher or extra options.
"junit" => {
let name = Path::new(&c.command).file_name().map(|n| n.to_string_lossy().to_ascii_lowercase());
match name.as_deref() {
Some(n) if n.starts_with("mvn") => apply_maven_env(c, root, s),
Some(n) if n.starts_with("gradle") => {
let build_dir =
c.cwd.as_ref().map(|p| root.join(p.as_ref())).unwrap_or_else(|| root.to_path_buf());
let env = gradle_build_env(root, &build_dir, &c.command, s, None);
apply_java_home(c, &env, java_home_is_derived(c));
for (key, value) in env {
c.env.entry(key).or_insert(value);
}
}
_ => {}
}
}
"maven" => {
apply_maven_env(c, root, s);
if let Some(mvn) = &mvn
+13
View File
@@ -409,6 +409,19 @@ fn project_jdk_and_maven_settings() {
);
assert_eq!(configs[0].env["JAVA_HOME"], home);
assert_eq!(configs[1].env["JAVA_HOME"], home);
// JUnit runs the build's own command line: the build's JDK, its command and arguments unchanged.
let mut junit = vec![
run("junit", "./mvnw", &["test", "-Dtest=a.T"]),
run("junit", "./gradlew", &[":api:test", "--tests", "a.T"]),
];
apply_to_run_configs(&mut junit, tmp.path(), &s);
assert_eq!(
(junit[0].command.as_str(), junit[0].args.as_slice()),
("./mvnw", &["test".to_owned(), "-Dtest=a.T".to_owned()][..])
);
assert_eq!(junit[0].env["JAVA_HOME"], home);
assert_eq!(junit[1].command, "./gradlew");
assert_eq!(junit[1].env["JAVA_HOME"], home);
apply_to_run_configs(&mut configs, tmp.path(), &s);
assert_eq!(configs[0].args.len(), 6, "applied once");
+35
View File
@@ -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
View File
@@ -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
+20 -1
View File
@@ -4,7 +4,7 @@ use crate::state::AppState;
use ide_agent::registry::AgentRegistry;
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
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]
+224
View File
@@ -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<&gtk::glib::Variant>,
) -> Option<gtk::glib::Variant> {
connection
.call_sync(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
method,
parameters,
None,
gio::DBusCallFlags::NONE,
2000,
gio::Cancellable::NONE,
)
.ok()
}
/// Whether `org.freedesktop.portal.Desktop` is running or can be started on demand.
pub fn portal_reachable() -> bool {
let Ok(connection) = gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE) else {
return false;
};
let running = bus_call(&connection, "NameHasOwner", Some(&(PORTAL,).to_variant()))
.and_then(|v| v.child_value(0).get::<bool>())
.unwrap_or(false);
running
|| bus_call(&connection, "ListActivatableNames", None)
.and_then(|v| v.child_value(0).get::<Vec<String>>())
.is_some_and(|names| names.iter().any(|n| n == PORTAL))
}
pub fn zenity_installed() -> bool {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join("zenity").is_file()))
}
pub fn pick(parent: Option<&gtk::Window>, options: &GtkPickOptions) -> Option<Vec<PathBuf>> {
let action = match (options.save, options.directory) {
(true, _) => gtk::FileChooserAction::Save,
(false, true) => gtk::FileChooserAction::SelectFolder,
(false, false) => gtk::FileChooserAction::Open,
};
let accept = if options.save { "_Save" } else { "_Open" };
let dialog = gtk::FileChooserDialog::with_buttons(
Some(options.title.as_deref().unwrap_or(if options.save { "Save" } else { "Open" })),
parent,
action,
&[("_Cancel", gtk::ResponseType::Cancel), (accept, gtk::ResponseType::Accept)],
);
dialog.set_select_multiple(options.multiple && !options.save);
dialog.set_do_overwrite_confirmation(options.save);
if let Some(default) = &options.default_path {
if default.is_dir() {
let _ = dialog.set_current_folder(default);
} else {
if let Some(parent) = default.parent().filter(|p| p.is_dir()) {
let _ = dialog.set_current_folder(parent);
}
if options.save {
if let Some(name) = default.file_name() {
dialog.set_current_name(&name.to_string_lossy());
}
} else if default.is_file() {
let _ = dialog.set_filename(default);
}
}
}
for filter in options.filters.iter().filter(|f| !f.extensions.is_empty()) {
let gtk_filter = gtk::FileFilter::new();
gtk_filter.set_name(Some(&filter.name));
for extension in &filter.extensions {
if extension == "*" {
gtk_filter.add_pattern("*");
} else {
// GTK patterns are case sensitive; match `.TXT` as well as `.txt`.
let ext: String = extension
.chars()
.map(|c| {
if c.is_ascii_alphabetic() {
format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase())
} else {
c.to_string()
}
})
.collect();
gtk_filter.add_pattern(&format!("*.{ext}"));
}
}
dialog.add_filter(gtk_filter);
}
let response = dialog.run();
let paths = (response == gtk::ResponseType::Accept).then(|| dialog.filenames());
// SAFETY: the dialog is ours, and not used after this.
unsafe { dialog.destroy() };
paths.filter(|p| !p.is_empty())
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use gtk::prelude::*;
/// Needs a display and no portal service, e.g. `xvfb-run` with no session bus.
#[test]
#[ignore = "needs a display"]
fn gtk_chooser_opens_without_a_portal_or_zenity() {
gtk::init().expect("a display");
assert!(!linux::portal_reachable(), "run without a session bus");
let dir = std::env::temp_dir();
let options = GtkPickOptions {
title: Some("Open Folder".into()),
default_path: Some(dir.clone()),
directory: true,
multiple: false,
save: false,
filters: vec![],
};
// Accept the chooser once it is on screen, and note its title.
gtk::glib::timeout_add_local(std::time::Duration::from_millis(500), || {
let chooser =
gtk::Window::list_toplevels().into_iter().find_map(|w| w.downcast::<gtk::FileChooserDialog>().ok());
let chooser = chooser.expect("the chooser is shown");
assert_eq!(chooser.title().as_deref(), Some("Open Folder"));
assert!(chooser.is_visible());
chooser.response(gtk::ResponseType::Accept);
gtk::glib::ControlFlow::Break
});
let picked = linux::pick(None, &options).expect("the chooser returned the accepted folder");
assert_eq!(picked, vec![dir]);
}
}
+6
View File
@@ -22,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,
+11
View File
@@ -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 -1
View File
@@ -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",
+42
View File
@@ -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,
+1 -1
View File
@@ -26,7 +26,7 @@ async function retrieve(path: string, choose: boolean) {
try {
let sources: string | null = null;
if (choose) {
const { open } = await import("@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"] }],
+12
View File
@@ -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;
}
+45 -1
View File
@@ -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>
+28 -38
View File
@@ -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;
+5 -47
View File
@@ -75,7 +75,7 @@ import { ConfigPickers } from "./ConfigPickers";
import { findRanges, formatDuration, formatSentAt } from "./conversation";
import { ImagePreview, imageSrc } from "./ImagePreview";
import { useAgentSettings } from "./agentSettings";
import { 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 -1
View File
@@ -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,
+59 -2
View File
@@ -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();
});
});
+27
View File
@@ -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
View File
@@ -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", () => {
-9
View File
@@ -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;
+1 -1
View File
@@ -40,7 +40,7 @@ export function AnalysisDialogHost() {
async function browseDirectory(initial: string): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@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;
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -55,7 +55,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
setAdding("");
};
const browse = async (target: "workspace" | "project") => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const result = await open({
directory: true,
multiple: target === "project",
+2 -2
View File
@@ -56,7 +56,7 @@ function errorText(e: unknown) {
}
async function exportSettings() {
const { save } = await import("@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,
+2 -2
View File
@@ -208,14 +208,14 @@ export function defaultProjectDirectory(): string | undefined {
}
async function pickFolder(title: string): Promise<string | null> {
const { open } = await import("@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];
}
+47 -3
View File
@@ -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"] } } },
+47 -11
View File
@@ -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,
+1 -1
View File
@@ -27,7 +27,7 @@ import styles from "./Coverage.module.css";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
async function pickReport(): Promise<string | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const file = await open({
multiple: false,
directory: false,
+1 -1
View File
@@ -149,7 +149,7 @@ export function ImportDialog({ id, schema, table }: { id: string; schema: string
const choose = async () => {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const f = await open({
multiple: false,
directory: false,
+13 -2
View File
@@ -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);
};
+1 -1
View File
@@ -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);
+6 -4
View File
@@ -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")}
+10
View File
@@ -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", () => {
+1 -1
View File
@@ -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 }
+8 -3
View File
@@ -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;
}
/**
+1 -1
View File
@@ -149,7 +149,7 @@ export async function insertScript(n: DbNode, kind: ScriptKind) {
/** SQL Scripts › Run SQL Script…: picks a `.sql` file and runs all its statements. */
async function runScriptFile(id: string) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const file = await open({
multiple: false,
directory: false,
+1 -1
View File
@@ -21,7 +21,7 @@ const act = (
const sep: MenuItem = { kind: "separator" };
async function sqliteFromFile() {
const { open } = await import("@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) });
}
+32 -3
View 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" });
});
});
+3 -3
View File
@@ -30,7 +30,7 @@ import { fieldAt, methodAt } from "./breakpoints/symbols";
import { toggleBreakpointSmart } from "./breakpoints/toggle";
import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java";
import { cancelHotSwapBuild, reloadChangedClasses } from "./hotswap";
import { debugUnsupportedReason, launchConfigFor } 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;
+2
View File
@@ -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();
+13 -1
View File
@@ -1,7 +1,7 @@
import { parseArgs, formatArgs } from "@/platform/commandLine";
/** Run configuration → DAP launch configuration, per adapter. */
import { workflowSettings } from "@/platform/workflowSettings";
import 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;
+23
View File
@@ -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({
+6 -6
View File
@@ -214,7 +214,7 @@ export const tauriPluginsBackend: PluginsBackend = {
},
program: (id, program, scope) => call(commands.pluginProgram(id, program, scope)),
pickProgram: async () => {
const { open } = await import("@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;
},
+1 -1
View File
@@ -374,7 +374,7 @@ async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] |
case "pickDirectory": {
if (signal.aborted) return null;
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
if (signal.aborted) return null;
const selected = await open({
directory: true,
+10 -4
View File
@@ -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"));
});
});
+3 -4
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -203,7 +203,7 @@ export interface ConfigFormProps {
/** Picks files or a directory in the local file dialog, opened at `from`; empty when cancelled. */
async function choosePaths(opts: { directory?: boolean; multiple?: boolean; from: string | null }): Promise<string[]> {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: !!opts.directory,
multiple: !!opts.multiple,
+1 -1
View File
@@ -72,7 +72,7 @@ function serializeEnvPairs(rows: ReadonlyArray<{ name: string; value: string }>)
async function pickLocalPath(directory: boolean, defaultPath?: string): Promise<string | null> {
if (!isTauri() || isRemoteWindow()) return null;
const { open } = await import("@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;
}
+1 -1
View File
@@ -89,7 +89,7 @@ import styles from "./FindInFiles.module.css";
/** The Directory scope's browse button: a folder chooser opened at the current directory. */
async function chooseDirectory(current: string): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
+1 -1
View File
@@ -90,7 +90,7 @@ function PromptDialog(props: {
}
async function pickDirectory(title: string): Promise<string | null> {
const { open: openDialog } = await import("@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;
}
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more