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
91 changed files with 2531 additions and 222 deletions

No files matched your search

+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
});
+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"] {
+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/*");
}
}
+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);
});
+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]);
}
}
+5
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,
+40
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). */
@@ -4299,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,
@@ -4877,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,
@@ -8811,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) });
}
+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;
}
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { PageContext } from "../components/context";
@@ -50,7 +50,9 @@ describe("Java formatting profile", () => {
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "config/style.xml" } });
mocks.open.mockResolvedValue("/project/custom.xml");
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Browse…" })));
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } });
await waitFor(() =>
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } }),
);
expect(mocks.open).toHaveBeenCalledWith(
expect.objectContaining({ filters: [{ name: "XML", extensions: ["xml"] }] }),
);
+1 -1
View File
@@ -231,7 +231,7 @@ export function KeymapPage() {
};
const importIdeaKeymap = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const path = await open({ multiple: false, filters: [{ name: tx("IntelliJ IDEA Keymap"), extensions: ["xml"] }] });
if (!path) return;
let parsed;
+1 -1
View File
@@ -135,7 +135,7 @@ export async function choosePath(opts: {
defaultPath?: string;
}): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: opts.directory,
multiple: false,
+1 -1
View File
@@ -431,7 +431,7 @@ function DefaultDirectoryRow() {
const [dir, setDir] = useSetting<string>("general.defaultProjectDirectory");
const id = useId();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
@@ -254,7 +254,7 @@ function JavaFormatter({
}) {
const id = useId();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const path = await open({
multiple: false,
title: tx("Eclipse formatter XML"),
@@ -1153,7 +1153,7 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
useEffect(() => {
handlers.current = {
import: async (file) => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const path =
file ??
(await open({
@@ -1184,7 +1184,7 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
setReport(tx`Applied ${parsed.applied} values; skipped ${parsed.skipped} unsupported or invalid values.`);
},
export: async (file) => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
const path =
file ??
(await save({
+1 -1
View File
@@ -103,7 +103,7 @@ function KeyFileRow({ value, onChange }: { value: string; onChange: (v: string)
const id = useId();
const tx = useTx();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ multiple: false, title: tx("Private Key File") });
const path = Array.isArray(picked) ? picked[0] : picked;
if (path) onChange(path);
+2 -1
View File
@@ -127,7 +127,7 @@ function ShellPathRow() {
void commands.termAvailableShells().then(setShells, () => undefined);
}, []);
const browse = async () => {
const { open: pick } = await import("@tauri-apps/plugin-dialog");
const { open: pick } = await import("@/platform/dialog");
const picked = await pick({ multiple: false, title: "Shell Path" });
const path = Array.isArray(picked) ? picked[0] : picked;
if (path) set(quotePath(path));
@@ -238,6 +238,7 @@ function TerminalOptions() {
/>
<BoundToggle k="terminal.shellIntegration" label="Shell integration" />
<BoundToggle k="terminal.highlightHyperlinks" label="Highlight hyperlinks" />
<BoundToggle k="terminal.highlightSelection" label="Highlight occurrences of selected text" />
{isMac && <BoundToggle k="terminal.optionAsMeta" label="Use Option as Meta key" />}
</>
}
+17
View File
@@ -113,6 +113,23 @@
.pane :global(.xterm-viewport) {
background-color: var(--bg-island, var(--bg-app)) !important;
}
.pane :global(.xterm-screen) {
isolation: isolate;
}
.pane :global(.openidea-terminal-selection-matches) {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}
.pane :global(.openidea-terminal-selection-match) {
position: absolute;
box-sizing: border-box;
border-radius: 2px;
background-color: var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent));
outline: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
outline-offset: -1px;
}
.pane :global(.xterm .xterm-scrollable-element > .scrollbar > .slider) {
box-sizing: border-box;
border: 2px solid transparent;
+172
View File
@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { Terminal } from "@xterm/xterm";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
findVisibleSelectionMatches,
installSelectionHighlights,
OVERLAY_CONTAINER_CLASS,
OVERLAY_MATCH_CLASS,
SelectionLineCache,
} from "./highlight";
function writeAsync(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve));
}
describe("terminal selection occurrence highlights", () => {
const terms: Array<{ term: Terminal; host: HTMLDivElement }> = [];
const createTerm = (cols = 40, rows = 5) => {
window.matchMedia ??= (() =>
({
matches: false,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
}) as unknown as MediaQueryList) as typeof window.matchMedia;
const host = document.createElement("div");
document.body.appendChild(host);
const term = new Terminal({ allowProposedApi: true, cols, rows, scrollback: 200 });
term.open(host);
terms.push({ term, host });
return term;
};
afterEach(() => {
while (terms.length) {
const { term, host } = terms.pop()!;
term.dispose();
host.remove();
}
});
it("scans only visible rows (plus margin), never the distant scrollback", async () => {
const term = createTerm(40, 4);
for (let i = 0; i < 40; i++) {
await writeAsync(term, `line-${i} req-42 req-42\r\n`);
}
const lastRow = term.buffer.active.baseY;
// Select the first "req-42" on the last visible row (col 8, len 6)
term.select(8, lastRow, 6);
expect(term.getSelection()).toBe("req-42");
const cache = new SelectionLineCache();
const matches = findVisibleSelectionMatches(term, cache, 2);
// Distant scrollback rows (e.g. row 0..20) must never be scanned or returned
expect(matches.every((m) => m.row >= term.buffer.active.viewportY - 2)).toBe(true);
expect(cache.scans).toBeLessThanOrEqual(8);
});
it("caches per line and rescans only invalidated rows", async () => {
const term = createTerm(40, 5);
await writeAsync(term, "pid-99 other pid-99\r\npid-99 second\r\nthird line\r\n");
term.select(0, 0, 6);
const cache = new SelectionLineCache();
const first = findVisibleSelectionMatches(term, cache, 2);
expect(first).toEqual([
{ row: 0, col: 13, size: 6 },
{ row: 1, col: 0, size: 6 },
]);
const initialScans = cache.scans;
expect(initialScans).toBeGreaterThan(0);
// Second pass with unchanged lines hits the cache (0 new scans)
const second = findVisibleSelectionMatches(term, cache, 2);
expect(second).toEqual(first);
expect(cache.scans).toBe(initialScans);
// Invalidating row 1 rescans only row 1
cache.invalidateRange(1, 1);
findVisibleSelectionMatches(term, cache, 2);
expect(cache.scans).toBe(initialScans + 1);
});
it("maps wrapped lines (isWrapped) and wide CJK characters to exact cell columns", async () => {
const term = createTerm(12, 6);
// "中文ab中文" -> '中'(2) '文'(2) 'a'(1) 'b'(1) '中'(2) '文'(2) = 10 cols
await writeAsync(term, "中文ab中文\r\n");
// Select first "中文" (cols 0..4)
term.select(0, 0, 4);
expect(term.getSelection()).toBe("中文");
const cjkMatches = findVisibleSelectionMatches(term);
expect(cjkMatches).toEqual([{ row: 0, col: 6, size: 4 }]);
// Wrapped line across 12-column terminal: "0123456789tok_en_012345tok_en"
term.reset();
await writeAsync(term, "0123456789tok_en_012345tok_en\r\n");
// First "tok_en" starts at col 10 of row 0 and wraps 4 cols into row 1 (size 6)
term.select(10, 0, 6);
expect(term.getSelection()).toBe("tok_en");
const wrappedMatches = findVisibleSelectionMatches(term);
expect(wrappedMatches).toEqual([{ row: 1, col: 11, size: 6 }]);
});
it("skips multi-line, short, or whitespace-only selections and turns off in alternate buffer", async () => {
const term = createTerm(40, 6);
await writeAsync(term, "a a a\r\n \r\nfoo bar foo\r\nfoo baz\r\n");
term.select(0, 0, 1);
expect(findVisibleSelectionMatches(term)).toEqual([]);
term.select(0, 1, 3);
expect(findVisibleSelectionMatches(term)).toEqual([]);
term.selectLines(2, 3);
expect(findVisibleSelectionMatches(term)).toEqual([]);
term.select(0, 2, 3);
expect(findVisibleSelectionMatches(term)).toHaveLength(2);
// Enter alternate screen buffer (vim / htop)
await writeAsync(term, "\x1b[?1049hfoo bar foo\r\n");
expect(term.buffer.active.type).toBe("alternate");
expect(findVisibleSelectionMatches(term)).toEqual([]);
});
it("draws highlights in one overlay layer, coalesces frames, and clears during heavy output", async () => {
const term = createTerm(40, 6);
const registerSpy = vi.spyOn(term, "registerDecoration");
let enabled = true;
const controller = installSelectionHighlights(term, {
isEnabled: () => enabled,
});
await writeAsync(term, "err_code other err_code\r\nerr_code third\r\n");
term.select(0, 0, 8);
controller.refresh();
// Zero registerDecoration calls: uses one overlay container instead
expect(registerSpy).not.toHaveBeenCalled();
const overlay = term.element?.querySelector(`.${OVERLAY_CONTAINER_CLASS}`);
expect(overlay).not.toBeNull();
const visibleBoxes = () =>
[...(overlay?.querySelectorAll<HTMLElement>(`.${OVERLAY_MATCH_CLASS}`) ?? [])].filter(
(el) => el.style.display !== "none",
);
expect(visibleBoxes()).toHaveLength(2);
// Disabling the setting clears the overlay
enabled = false;
controller.refresh();
expect(visibleBoxes()).toHaveLength(0);
enabled = true;
controller.refresh();
expect(visibleBoxes()).toHaveLength(2);
// Heavy output burst clears the overlay immediately and redraws once settled
await writeAsync(term, "streaming line 1\r\n");
await writeAsync(term, "streaming line 2 err_code\r\n");
expect(visibleBoxes()).toHaveLength(0);
await new Promise((r) => setTimeout(r, 220));
controller.refresh();
expect(visibleBoxes().length).toBeGreaterThanOrEqual(2);
controller.dispose();
});
});
+470
View File
@@ -0,0 +1,470 @@
/**
* Viewport-scoped selection-occurrence highlights in the terminal.
*
* - Scans only the visible rows (plus a small margin), never the scrollback.
* - Caches per logical line and rescans only rows invalidated by `onRender`.
* - Draws all visible matches in one overlay layer (`.openidea-terminal-selection-matches`)
* inside `.xterm-screen` instead of calling `registerDecoration` per match.
* - Coalesces `onRender` / `onScroll` / `onSelectionChange` to one pass per
* animation frame; clears highlights during heavy output bursts and redraws
* once output settles.
* - Handles wrapped lines (`isWrapped`) and wide (CJK / emoji) characters.
* - Skips multi-line, short (< 2 chars), long (> 200 chars), or whitespace-only
* selections, turns off in the alternate buffer (`vim`, `htop`), and respects
* `terminal.highlightSelection`.
*/
import type { IBufferLine, IDisposable, Terminal } from "@xterm/xterm";
import { workflowSettings } from "@/platform/workflowSettings";
export const MIN_SELECTION_LENGTH = 2;
export const MAX_SELECTION_LENGTH = 200;
export const VIEWPORT_MARGIN_ROWS = 4;
const MAX_WRAP_ROWS = 16;
const MAX_VISIBLE_MATCHES = 200;
const HEAVY_WRITE_WINDOW_MS = 80;
const HEAVY_SETTLE_MS = 150;
export const OVERLAY_CONTAINER_CLASS = "openidea-terminal-selection-matches";
export const OVERLAY_MATCH_CLASS = "openidea-terminal-selection-match";
export interface TerminalBufferMatch {
row: number;
col: number;
size: number;
}
interface CachedLogicalLine {
span: number;
matches: TerminalBufferMatch[];
}
export class SelectionLineCache {
private needle = "";
private cols = 0;
private readonly lines = new Map<number, CachedLogicalLine>();
/** Number of logical-line scans performed (exposed for unit tests). */
scans = 0;
syncKey(needle: string, cols: number) {
if (this.needle !== needle || this.cols !== cols) {
this.needle = needle;
this.cols = cols;
this.lines.clear();
}
}
get(baseRow: number): CachedLogicalLine | undefined {
return this.lines.get(baseRow);
}
set(baseRow: number, entry: CachedLogicalLine) {
this.lines.set(baseRow, entry);
}
invalidateRange(startRow: number, endRow: number) {
for (const [baseRow, entry] of this.lines) {
if (baseRow <= endRow && baseRow + entry.span - 1 >= startRow) {
this.lines.delete(baseRow);
}
}
}
prune(minRow: number, maxRow: number) {
for (const baseRow of this.lines.keys()) {
if (baseRow < minRow || baseRow > maxRow) {
this.lines.delete(baseRow);
}
}
}
clear() {
this.lines.clear();
}
}
function translateLogicalLine(term: Terminal, baseRow: number, maxRow: number): [string, number[], number] {
const buffer = term.buffer.active;
const strings: string[] = [];
const lineOffsets = [0];
let lineIndex = baseRow;
let line = buffer.getLine(lineIndex);
let span = 0;
while (line) {
span++;
const nextLine = lineIndex < maxRow ? buffer.getLine(lineIndex + 1) : undefined;
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
let str = line.translateToString(!lineWrapsToNext);
if (lineWrapsToNext && nextLine) {
const lastCell = line.getCell(line.length - 1);
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
str = str.slice(0, -1);
}
}
strings.push(str);
if (lineWrapsToNext) {
lineOffsets.push(lineOffsets[lineOffsets.length - 1]! + str.length);
} else {
break;
}
lineIndex++;
line = nextLine;
}
return [strings.join(""), lineOffsets, Math.max(1, span)];
}
/**
* Maps a UTF-16 string offset within `line.translateToString()` to a 0-based
* buffer column, accounting for width-2 CJK glyphs, width-0 trailing spacer
* cells, and astral surrogate pairs.
*/
export function stringOffsetToCol(line: IBufferLine | undefined, targetOffset: number): number {
if (!line || targetOffset <= 0) return 0;
let strPos = 0;
let col = 0;
const len = line.length;
while (col < len && strPos < targetOffset) {
const cell = line.getCell(col);
if (!cell) break;
const width = cell.getWidth();
if (width === 0) {
col++;
continue;
}
const chars = cell.getChars();
strPos += chars ? chars.length : 1;
col += width;
}
return col;
}
function scanLogicalLineMatches(term: Terminal, baseRow: number, maxRow: number, needle: string): CachedLogicalLine {
const cols = term.cols;
const buffer = term.buffer.active;
const [stringLine, offsets, span] = translateLogicalLine(term, baseRow, maxRow);
const matches: TerminalBufferMatch[] = [];
if (stringLine.length < needle.length) {
return { span, matches };
}
let fromIndex = 0;
while (fromIndex <= stringLine.length - needle.length) {
const idx = stringLine.indexOf(needle, fromIndex);
if (idx < 0) break;
let startRowOffset = 0;
while (startRowOffset < offsets.length - 1 && idx >= offsets[startRowOffset + 1]!) {
startRowOffset++;
}
let endRowOffset = startRowOffset;
while (endRowOffset < offsets.length - 1 && idx + needle.length > offsets[endRowOffset + 1]!) {
endRowOffset++;
}
const startColOffset = idx - offsets[startRowOffset]!;
const endColOffset = idx + needle.length - offsets[endRowOffset]!;
const startColIndex = stringOffsetToCol(buffer.getLine(baseRow + startRowOffset), startColOffset);
const endColIndex = stringOffsetToCol(buffer.getLine(baseRow + endRowOffset), endColOffset);
const matchRow = baseRow + startRowOffset;
const size = endColIndex - startColIndex + cols * (endRowOffset - startRowOffset);
if (size > 0) {
matches.push({ row: matchRow, col: startColIndex, size });
}
fromIndex = idx + needle.length;
}
return { span, matches };
}
/**
* Finds case-sensitive occurrences of `term.getSelection()` in the visible
* viewport rows (`viewportY - marginRows .. viewportY + rows - 1 + marginRows`),
* excluding the active selection itself and skipping the alternate buffer.
*/
export function findVisibleSelectionMatches(
term: Terminal,
cache?: SelectionLineCache,
marginRows = VIEWPORT_MARGIN_ROWS,
): TerminalBufferMatch[] {
const buffer = term.buffer.active;
if (buffer.type === "alternate") return [];
const text = term.getSelection();
if (
text.length < MIN_SELECTION_LENGTH ||
text.length > MAX_SELECTION_LENGTH ||
text.includes("\n") ||
text.includes("\r") ||
!text.trim()
) {
return [];
}
const cols = term.cols;
if (cols <= 0 || term.rows <= 0) return [];
const selPos = term.getSelectionPosition();
const selStart = selPos ? selPos.start.y * cols + selPos.start.x : -1;
const selEnd = selPos ? selPos.end.y * cols + selPos.end.x : -1;
const totalRows = Math.max(buffer.length, buffer.baseY + term.rows);
if (totalRows <= 0) return [];
const viewStart = Math.max(0, buffer.viewportY - marginRows);
const viewEnd = Math.min(totalRows - 1, buffer.viewportY + term.rows - 1 + marginRows);
const maxWrapEnd = Math.min(totalRows - 1, viewEnd + MAX_WRAP_ROWS);
cache?.syncKey(text, cols);
cache?.prune(Math.max(0, viewStart - MAX_WRAP_ROWS), maxWrapEnd);
const visited = new Set<number>();
const out: TerminalBufferMatch[] = [];
for (let row = viewStart; row <= viewEnd; row++) {
let baseRow = row;
let steps = 0;
while (baseRow > 0 && steps < MAX_WRAP_ROWS && buffer.getLine(baseRow)?.isWrapped) {
baseRow--;
steps++;
}
if (visited.has(baseRow)) continue;
visited.add(baseRow);
let entry = cache?.get(baseRow);
if (!entry) {
if (cache) cache.scans++;
entry = scanLogicalLineMatches(term, baseRow, maxWrapEnd, text);
cache?.set(baseRow, entry);
}
for (const match of entry.matches) {
const matchStart = match.row * cols + match.col;
const matchEnd = matchStart + match.size;
const overlapsSelection = selPos !== undefined && matchStart < selEnd && selStart < matchEnd;
if (!overlapsSelection) {
out.push(match);
if (out.length >= MAX_VISIBLE_MATCHES) return out;
}
}
}
return out;
}
export interface SelectionHighlightOptions {
isEnabled?: () => boolean;
isSearchSelection?: () => boolean;
}
export interface SelectionHighlightController extends IDisposable {
readonly cache: SelectionLineCache;
refresh(): void;
clear(): void;
}
export function installSelectionHighlights(
term: Terminal,
opts: SelectionHighlightOptions = {},
): SelectionHighlightController {
const isEnabled = opts.isEnabled ?? (() => workflowSettings().terminal.highlightSelection);
const isSearchSelection = opts.isSearchSelection ?? (() => false);
const cache = new SelectionLineCache();
let overlay: HTMLDivElement | null = null;
const pool: HTMLDivElement[] = [];
let activeBoxes = 0;
let rafId = 0;
let lastWriteMs = 0;
let heavyOutput = false;
let settleTimer: ReturnType<typeof setTimeout> | undefined;
const ensureOverlay = (): HTMLDivElement | null => {
if (overlay && overlay.isConnected) return overlay;
const screen = term.element?.querySelector<HTMLElement>(".xterm-screen");
if (!screen) return null;
if (!overlay) {
overlay = document.createElement("div");
overlay.className = OVERLAY_CONTAINER_CLASS;
overlay.setAttribute("aria-hidden", "true");
}
screen.prepend(overlay);
return overlay;
};
const hideBoxesFrom = (startIndex: number) => {
for (let i = startIndex; i < activeBoxes; i++) {
const box = pool[i];
if (box) box.style.display = "none";
}
activeBoxes = startIndex;
};
const clearOverlay = () => {
hideBoxesFrom(0);
};
const cellDimensions = (screen: HTMLElement): { cellWidth: number; cellHeight: number } => {
const w = parseFloat(screen.style.width) || screen.clientWidth;
const h = parseFloat(screen.style.height) || screen.clientHeight;
const cellWidth = w > 0 && term.cols > 0 ? w / term.cols : 8;
const cellHeight = h > 0 && term.rows > 0 ? h / term.rows : 16;
return { cellWidth, cellHeight };
};
const recompute = () => {
rafId = 0;
if (
!isEnabled() ||
isSearchSelection() ||
heavyOutput ||
!term.hasSelection() ||
term.buffer.active.type === "alternate"
) {
clearOverlay();
return;
}
const container = ensureOverlay();
if (!container || !container.parentElement) {
clearOverlay();
return;
}
const matches = findVisibleSelectionMatches(term, cache);
if (!matches.length) {
clearOverlay();
return;
}
const { cellWidth, cellHeight } = cellDimensions(container.parentElement);
const cols = term.cols;
const rows = term.rows;
const viewportY = term.buffer.active.viewportY;
let boxIdx = 0;
for (const match of matches) {
let currentCol = match.col;
let remaining = match.size;
let currentRow = match.row;
while (remaining > 0) {
const width = Math.min(cols - currentCol, remaining);
const viewportRow = currentRow - viewportY;
if (viewportRow >= 0 && viewportRow < rows) {
let box = pool[boxIdx];
if (!box) {
box = document.createElement("div");
box.className = OVERLAY_MATCH_CLASS;
pool.push(box);
container.appendChild(box);
}
box.style.display = "block";
box.style.top = `${viewportRow * cellHeight}px`;
box.style.left = `${currentCol * cellWidth}px`;
box.style.width = `${width * cellWidth}px`;
box.style.height = `${cellHeight}px`;
boxIdx++;
}
currentCol = 0;
remaining -= width;
currentRow++;
}
}
hideBoxesFrom(boxIdx);
};
const scheduleFrame = () => {
if (rafId !== 0 || heavyOutput) return;
rafId = window.requestAnimationFrame(recompute);
};
const cancelFrame = () => {
if (rafId !== 0) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
};
const onWriteParsed = () => {
if (!term.hasSelection() || isSearchSelection() || !isEnabled()) return;
const now = performance.now();
const isBurst = now - lastWriteMs <= HEAVY_WRITE_WINDOW_MS;
lastWriteMs = now;
if (isBurst || heavyOutput) {
heavyOutput = true;
cancelFrame();
clearOverlay();
if (settleTimer !== undefined) clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleTimer = undefined;
heavyOutput = false;
cache.clear();
scheduleFrame();
}, HEAVY_SETTLE_MS);
}
};
const disposables: IDisposable[] = [
term.onSelectionChange(() => {
if (!term.hasSelection() || isSearchSelection()) {
cancelFrame();
clearOverlay();
return;
}
scheduleFrame();
}),
term.onScroll(() => {
if (term.hasSelection() && !isSearchSelection() && !heavyOutput) {
scheduleFrame();
}
}),
term.onRender(({ start, end }) => {
const viewportY = term.buffer.active.viewportY;
cache.invalidateRange(viewportY + start, viewportY + end);
if (term.hasSelection() && !isSearchSelection() && !heavyOutput) {
scheduleFrame();
}
}),
term.onResize(() => {
cache.clear();
if (term.hasSelection() && !isSearchSelection() && !heavyOutput) {
scheduleFrame();
}
}),
term.onWriteParsed(onWriteParsed),
term.buffer.onBufferChange(() => {
cache.clear();
if (term.buffer.active.type === "alternate") {
cancelFrame();
clearOverlay();
} else if (term.hasSelection() && !isSearchSelection()) {
scheduleFrame();
}
}),
];
return {
cache,
refresh() {
cache.clear();
cancelFrame();
recompute();
},
clear() {
cancelFrame();
clearOverlay();
},
dispose() {
cancelFrame();
if (settleTimer !== undefined) {
clearTimeout(settleTimer);
settleTimer = undefined;
}
for (const d of disposables) d.dispose();
overlay?.remove();
overlay = null;
pool.length = 0;
activeBoxes = 0;
cache.clear();
},
};
}
+18 -1
View File
@@ -16,6 +16,7 @@ import { fitScheduler, settledResize } from "./fit";
import { watchWindowsPty, windowsPty } from "./windowsPty";
import { installTerminalDrop } from "./drop";
import { installDomRowCache } from "./domRowCache";
import { installSelectionHighlights, type SelectionHighlightController } from "./highlight";
import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
import { ImeCommitGate } from "./imeCommit";
import { ownTerminalInput } from "./imeInput";
@@ -206,6 +207,9 @@ export class TerminalSession {
private readonly searchAddon = new SearchAddon({ highlightLimit: 2000 });
private searchDecorations: ISearchOptions["decorations"] | null = null;
private readonly searchListeners = new Set<(r: TerminalSearchResult) => void>();
private selectingSearchMatch = false;
private searchSelection = false;
private readonly selectionHighlights: SelectionHighlightController;
private readonly opts: SessionOptions;
private snapshot: SessionSnapshot;
private ptyId: TerminalId | null = null;
@@ -309,7 +313,9 @@ export class TerminalSession {
if (workflowSettings().terminal.audibleBell) beep();
}),
this.term.onSelectionChange(() => {
if (workflowSettings().terminal.copyOnSelection && this.term.hasSelection()) this.copySelection();
this.searchSelection = this.selectingSearchMatch && this.term.hasSelection();
if (!this.searchSelection && workflowSettings().terminal.copyOnSelection && this.term.hasSelection())
this.copySelection();
}),
// Mouse reporting off: ignore the application's requests to enable mouse tracking.
this.term.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
@@ -318,6 +324,10 @@ export class TerminalSession {
return modes.length > 0 && modes.every((m) => MOUSE_MODES.has(m));
}),
);
this.selectionHighlights = installSelectionHighlights(this.term, {
isSearchSelection: () => this.searchSelection,
});
this.disposables.push(this.selectionHighlights);
this.disposables.push(
{ dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.clipboardTarget) },
{ dispose: installTerminalDrop(this.host, (text) => this.paste(text)) },
@@ -385,6 +395,7 @@ export class TerminalSession {
o.cursorStyle = CURSOR[t.cursorShape];
o.cursorBlink = t.cursorBlink;
o.macOptionIsMeta = t.optionAsMeta;
this.selectionHighlights.refresh();
this.scheduleFit();
}
@@ -756,6 +767,7 @@ export class TerminalSession {
this.clearSearch();
return false;
}
this.selectionHighlights.clear();
this.searchDecorations ??= readSearchDecorations();
const options: ISearchOptions = {
...opts,
@@ -763,6 +775,7 @@ export class TerminalSession {
decorations: this.searchDecorations,
};
try {
this.selectingSearchMatch = true;
return direction === "next"
? this.searchAddon.findNext(query, options)
: this.searchAddon.findPrevious(query, options);
@@ -770,12 +783,16 @@ export class TerminalSession {
// Invalid regular expression.
this.clearSearch();
return false;
} finally {
this.selectingSearchMatch = false;
}
}
clearSearch() {
this.searchAddon.clearDecorations();
this.term.clearSelection();
this.searchSelection = false;
this.selectionHighlights.clear();
for (const l of this.searchListeners) l({ index: -1, count: 0 });
}
+1 -1
View File
@@ -38,7 +38,7 @@ export const tauriToolsBackend: ToolsBackend = {
await call(commands.lspRestartNamed(name));
},
chooseExecutable: async (title, defaultPath) => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory: false, multiple: false, title, defaultPath: defaultPath || undefined });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
+1 -3
View File
@@ -38,9 +38,7 @@ interface RevisionData {
export async function openExternalLog() {
const picked = isTauri()
? await (
await import("@tauri-apps/plugin-dialog")
).open({ title: tx("Open Git Log"), directory: true, multiple: true })
? await (await import("@/platform/dialog")).open({ title: tx("Open Git Log"), directory: true, multiple: true })
: await choosePath({ title: "Open Git Log", directory: true });
const paths = Array.isArray(picked) ? picked : picked ? [picked] : [];
if (!paths.length) return;
+1 -1
View File
@@ -156,7 +156,7 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
};
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
+2 -2
View File
@@ -75,7 +75,7 @@ function info(title: string) {
async function pickFile(near: string): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const dir = near.slice(0, near.lastIndexOf("/")) || undefined;
const picked = await open({ multiple: false, directory: false, title: "Compare With", defaultPath: dir });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
@@ -129,7 +129,7 @@ async function pickDirectory(near: string): Promise<string | null> {
const picked = await promptText({ title: "Compare With", label: "Directory path", initial: near, mono: true });
return picked?.value ?? null;
}
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ multiple: false, directory: true, title: tx("Compare With"), defaultPath: near });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
}
+1 -1
View File
@@ -693,7 +693,7 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin
const move = async () => {
if (!current || !currentGroup || current.main || containsProject) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
+2 -2
View File
@@ -76,7 +76,7 @@ export function showCreatePatchForShelf(entry: ShelvedChangeList, path?: string)
async function browseSave(current: string): Promise<string | null> {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
return (await save({ defaultPath: current, filters: [{ name: "Patch", extensions: ["patch", "diff"] }] })) ?? null;
} catch {
return null;
@@ -228,7 +228,7 @@ export async function showApplyPatch(from: "file" | "clipboard", path?: string)
} else {
let file = path ?? null;
if (!file) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
multiple: false,
directory: false,
+1 -1
View File
@@ -386,7 +386,7 @@ export async function importShelfPatches(paths?: string[]) {
const repo = currentRepo();
const picked =
paths ??
(await import("@tauri-apps/plugin-dialog").then(({ open }) =>
(await import("@/platform/dialog").then(({ open }) =>
open({ multiple: true, directory: false, filters: [{ name: "Patch", extensions: ["patch", "diff"] }] }),
));
if (!picked) return;
@@ -156,7 +156,7 @@ function naturalSize(svg: SVGSVGElement): { w: number; h: number } {
async function saveDiagram(svg: SVGSVGElement): Promise<void> {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
const path = await save({
title: tx("Save diagram"),
defaultPath: "mermaid-diagram.png",
+16
View File
@@ -0,0 +1,16 @@
// @vitest-environment node
import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, it } from "vitest";
it("opens every file picker through the platform wrapper", () => {
const src = resolve(import.meta.dirname, "..");
const offenders = readdirSync(src, { recursive: true, encoding: "utf8" }).filter(
(file) =>
/\.[jt]sx?$/.test(file) &&
!/\.test\.[jt]sx?$/.test(file) &&
file !== "platform/dialog.ts" &&
readFileSync(resolve(src, file), "utf8").includes("@tauri-apps/plugin-" + "dialog"),
);
expect(offenders).toEqual([]);
});
+66
View File
@@ -0,0 +1,66 @@
/**
* File and folder pickers. Same calls as `@tauri-apps/plugin-dialog`, which shows the desktop's own
* chooser through xdg-desktop-portal on Linux. When a Linux session has neither a portal nor
* `zenity`, the plugin returns nothing and a picker looks cancelled, so those sessions get a GTK
* chooser from the backend instead.
*/
import {
open as pluginOpen,
save as pluginSave,
type OpenDialogOptions,
type OpenDialogReturn,
type SaveDialogOptions,
} from "@tauri-apps/plugin-dialog";
import { call, commands } from "@/platform/ipc";
let needsGtk: boolean | undefined;
// Asked once when the module loads, so a picker only waits for the answer if it opens first.
const probe = (async () => {
try {
needsGtk = await commands.fileDialogNeedsGtk();
} catch {
needsGtk = false;
}
})();
async function pluginDialogsUnavailable(): Promise<boolean> {
if (needsGtk === undefined) await probe;
return needsGtk === true;
}
type Filters = OpenDialogOptions["filters"];
function gtkFilters(filters: Filters) {
return (filters ?? []).map(({ name, extensions }) => ({ name, extensions }));
}
async function gtkPick(options: {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
save?: boolean;
filters?: Filters;
}): Promise<string[] | null> {
return call(
commands.gtkPickPaths({
title: options.title ?? null,
defaultPath: options.defaultPath ?? null,
directory: options.directory ?? false,
multiple: options.multiple ?? false,
save: options.save ?? false,
filters: gtkFilters(options.filters),
}),
);
}
export async function open<T extends OpenDialogOptions>(options: T = {} as T): Promise<OpenDialogReturn<T>> {
if (needsGtk === false || !(await pluginDialogsUnavailable())) return pluginOpen(options);
const paths = await gtkPick(options);
return (options.multiple ? paths : (paths?.[0] ?? null)) as OpenDialogReturn<T>;
}
export async function save(options: SaveDialogOptions = {}): Promise<string | null> {
if (needsGtk === false || !(await pluginDialogsUnavailable())) return pluginSave(options);
return (await gtkPick({ ...options, save: true }))?.[0] ?? null;
}
+3 -1
View File
@@ -4,6 +4,7 @@
"They go back to how they were when the task started, in the task's worktree.": "They go back to how they were when the task started, in the task's worktree.",
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.": "They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.",
"Cannot roll back the changes": "Cannot roll back the changes",
"Cannot connect": "Cannot connect",
"Rollback Selected Changes…": "Rollback Selected Changes…",
"Rollback All Changes…": "Rollback All Changes…",
"JUnit": "JUnit",
@@ -985,5 +986,6 @@
"Imported 1 MCP server from the project.": "Imported 1 MCP server from the project.",
"Imported {count} MCP servers from the project.": "Imported {count} MCP servers from the project.",
"Show branch and incoming/outgoing commits in the Project view": "Show branch and incoming/outgoing commits in the Project view",
"Up to date with {upstream}": "Up to date with {upstream}"
"Up to date with {upstream}": "Up to date with {upstream}",
"Highlight occurrences of selected text": "Highlight occurrences of selected text"
}
-1
View File
@@ -2016,7 +2016,6 @@
"Show Commit in Git Log": "Mostrar confirmación en el registro de Git",
"Show Container Log": "Mostrar registro del contenedor",
"Show Context Actions": "Mostrar acciones de contexto",
"Show changes": "Mostrar cambios",
"Show Coverage": "Mostrar cobertura",
"Show Debug Tool Window": "Mostrar ventana de herramientas de depuración",
"Show Diff": "Mostrar diferencias",
-1
View File
@@ -1928,7 +1928,6 @@
"Show Commit in Git Log": "Afficher le commit dans le journal Git",
"Show Container Log": "Afficher le journal du conteneur",
"Show Context Actions": "Afficher les actions contextuelles",
"Show changes": "Afficher les modifications",
"Show Coverage": "Afficher la couverture",
"Show Debug Tool Window": "Afficher la fenêtre d'outil Débogage",
"Show Diff": "Afficher le diff",
-1
View File
@@ -2072,7 +2072,6 @@
"Show Commit in Git Log": "Git 로그에서 커밋 표시",
"Show Container Log": "컨테이너 로그 표시",
"Show Context Actions": "컨텍스트 액션 표시",
"Show changes": "변경 사항 표시",
"Show Coverage": "커버리지 표시",
"Show Debug Tool Window": "디버그 도구 창 표시",
"Show Diff": "Diff 표시",
-1
View File
@@ -2003,7 +2003,6 @@
"Show Commit in Git Log": "Pokaż zatwierdzenie w dzienniku Git",
"Show Container Log": "Pokaż dziennik kontenera",
"Show Context Actions": "Pokaż akcje kontekstowe",
"Show changes": "Pokaż zmiany",
"Show Coverage": "Pokaż pokrycie kodu",
"Show Debug Tool Window": "Pokaż okno narzędziowe debugowania",
"Show Diff": "Pokaż różnice (Diff)",
-1
View File
@@ -2004,7 +2004,6 @@
"Show Commit in Git Log": "Mostrar commit no log do Git",
"Show Container Log": "Mostrar log do contêiner",
"Show Context Actions": "Mostrar ações de contexto",
"Show changes": "Mostrar alterações",
"Show Coverage": "Mostrar cobertura",
"Show Debug Tool Window": "Mostrar janela de ferramentas de depuração",
"Show Diff": "Mostrar diff",
+6 -2
View File
@@ -4,6 +4,7 @@
"They go back to how they were when the task started, in the task's worktree.": "它们会在任务的工作树中恢复为任务开始时的内容。",
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.": "它们会恢复为任务开始时的内容。任务开始后你对它们做的修改也会丢失;任务开始前的修改会保留。",
"Cannot roll back the changes": "无法回滚更改",
"Cannot connect": "无法连接",
"Rollback Selected Changes…": "回滚所选更改…",
"Rollback All Changes…": "回滚全部更改…",
"JUnit": "JUnit",
@@ -343,6 +344,8 @@
"Editor and Preview": "编辑器和预览",
"Preview Only": "仅预览",
"Add Maven Projects": "添加 Maven 项目",
"Some Maven projects were not added": "部分 Maven 项目未能添加",
"These are outside the open project, and Maven only links POMs inside it: {join}{more}": "这些项目位于已打开项目之外,而 Maven 仅链接项目内的 POM:{join}{more}",
"Unlink Maven Projects": "取消链接 Maven 项目",
"Unlink Maven projects?": "取消链接 Maven 项目?",
"Remove this project and its modules from Maven? Files will remain in the Project view.": "从 Maven 中移除此项目及其模块?文件将保留在项目视图中。",
@@ -2333,6 +2336,7 @@
"Services": "服务",
"Services actions": "服务操作",
"Session finished": "会话已结束",
"Sessions from other apps": "来自其他应用的智能体会话",
"Set Active Changelist": "设置活动更改列表",
"Set DEFAULT": "设为默认值",
"Set NULL": "设为 NULL",
@@ -2364,7 +2368,6 @@
"Show Commit in Git Log": "在 Git 日志中显示提交",
"Show Container Log": "显示容器日志",
"Show Context Actions": "显示上下文操作",
"Show changes": "显示更改",
"Show Coverage": "显示覆盖率",
"Show Debug Tool Window": "显示调试工具窗口",
"Show Diff": "显示差异",
@@ -7317,5 +7320,6 @@
"Imported 1 MCP server from the project.": "已从项目导入 1 个 MCP 服务器。",
"Imported {count} MCP servers from the project.": "已从项目导入 {count} 个 MCP 服务器。",
"Show branch and incoming/outgoing commits in the Project view": "在项目视图中显示分支及传入/传出提交",
"Up to date with {upstream}": "已与 {upstream} 同步"
"Up to date with {upstream}": "已与 {upstream} 同步",
"Highlight occurrences of selected text": "高亮所选文本的匹配项"
}
+3
View File
@@ -118,6 +118,7 @@ export interface TerminalSettingsValid {
overrideIdeShortcuts: boolean;
shellIntegration: boolean;
highlightHyperlinks: boolean;
highlightSelection: boolean;
optionAsMeta: boolean;
escapeToEditor: boolean;
cursorShape: CursorShape;
@@ -276,6 +277,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
overrideIdeShortcuts: true,
shellIntegration: true,
highlightHyperlinks: true,
highlightSelection: true,
optionAsMeta: false,
escapeToEditor: false,
cursorShape: "block",
@@ -444,6 +446,7 @@ export function validateTerminal(raw: unknown): TerminalSettingsValid {
overrideIdeShortcuts: bool(v.overrideIdeShortcuts, d.overrideIdeShortcuts),
shellIntegration: bool(v.shellIntegration, d.shellIntegration),
highlightHyperlinks: bool(v.highlightHyperlinks, d.highlightHyperlinks),
highlightSelection: bool(v.highlightSelection, d.highlightSelection),
optionAsMeta: bool(v.optionAsMeta, d.optionAsMeta),
escapeToEditor: bool(v.escapeToEditor, d.escapeToEditor),
cursorShape: oneOf(v.cursorShape, ["block", "underline", "bar"] as const, d.cursorShape),
+6
View File
@@ -50,6 +50,12 @@ html[data-selection-lock] *:not([data-selection-scope], [data-selection-scope] *
-webkit-user-select: none !important;
user-select: none !important;
}
/* WebKit keeps editable text selectable whatever its user-select, so a drag passing over a
text field would extend into it; take fields outside the scope out of hit testing instead. */
html[data-selection-lock]
:is(input, textarea, [contenteditable]):not([data-selection-scope], [data-selection-scope] *) {
pointer-events: none !important;
}
input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"]),
textarea,
+1 -1
View File
@@ -22,7 +22,7 @@ export async function openProjectDialog() {
});
return;
}
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const defaultPath = useProject.getState().settings?.general?.defaultProjectDirectory?.trim() || undefined;
const picked = await open({ directory: true, multiple: false, title: "Open Folder", defaultPath });
const path = Array.isArray(picked) ? picked[0] : picked;