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

...
Автор SHA1 Повідомлення Дата
rohitkumardandaтаrohitdanda 8df59cf5a7 fix(remote): decide routing, trust and accepted events on the desktop (#2999)
In a remote window the desktop took several decisions about itself from the server: it routed commands by the server's `commands` reply (a command the server left out ran locally), authorized its own commands from the server's `project_current().trusted`, and showed or ran whatever events the server forwarded, including any workbench action named by a plugin `executeCommand` event.

- Routing: commands go by `rpc::client_commands()`, the list a server of this build serves, compiled into the desktop; a test builds the server's dispatcher and compares the two.
- Trust: commands that run on the desktop follow the desktop's own decision, stored in `remote-project-trust.json` by target and root (a dev container also follows its local folder). The trust prompt appears unless the desktop's and the server's decisions agree; the answer and Revoke Trust update both. New commands `remote_trust_status` / `remote_trust_set`.
- Events: server `event`s pass an exhaustive allow-list (MCP approvals, update notices and window-group events are dropped); plugin `executeCommand` may only name `plugin.<id>.<command>`; `openProject` is dropped.
- Paths: a project root that the desktop would read as a share or device path is refused at connect.
- A remote plugin's `openUrl` and `copyToClipboard` ask first, showing the full address or the text.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 11:05:12 +08:00
Binтаffgenius 3c68d2ea7f fix(vcs): position commit history above message input (#3040)
Commit Message History opened below its button, over the message field, and the menu reserved an empty icon column. It now opens above the Amend bar, aligned with the message's left edge, and Menu takes iconColumn={false} to leave out the icon column for rows without icons.

Co-authored-by: ffgenius <+[email protected]>
2026-11-13 10:54:03 +08:00
l0ng-ai cfe1416dbd fix(index): keep the symbol index across releases that leave extraction unchanged (#3027)
The persistent symbol index was reset whenever Syntax::fingerprint() changed, and the fingerprint included CARGO_PKG_VERSION, so every release reparsed every file on first open.

The fingerprint is now a build-time hash of what decides extraction: ide-language's src/ and queries/, languages/*.toml, and the locked tree-sitter crates. The index stamp also carries a build-time hash of ide-index's own sources (ignored state, size limit, stored record), so a change there still resets the index without a manual SCHEMA bump.
2026-11-13 10:47:24 +08:00
l0ng-ai 44fc0a2ebc fix(java): build with Maven when JDT's annotation processing recreates a file (#3043)
With MapStruct-Plus, JDT runs MapStruct again over the mappers MapStruct-Plus
generated in the same build and fails with "FilerException: Source file already
created", so every run of the application stopped on that error. javac doesn't,
so a Maven module whose JDT build fails this way is now built with Maven, and a
launch already built with Maven ignores that JDT error instead of reporting the
build as failing.
2026-11-13 10:44:29 +08:00
28 змінених файлів з 930 додано та 94 видалено

No files matched your search

+69 -3
Переглянути файл
@@ -48,7 +48,7 @@ async fn resolve_launch(
let build = policy != BuildPolicy::NoBuild;
let full = policy == BuildPolicy::Rebuild;
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let (model, external, delegated, prefer_maven) = {
let (model, external, mut delegated, prefer_maven, is_maven) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
@@ -68,13 +68,29 @@ async fn resolve_launch(
prefer_maven,
);
let external = non_java || delegated;
(model, external, delegated, prefer_maven)
(model, external, delegated, prefer_maven, is_maven)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
};
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut external_build =
if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
if info.errors.iter().any(|e| processor_recreated_a_file(&e.message)) {
if build && !delegated && is_maven {
// JDT runs an annotation processor again over sources it generated in the same build
// (MapStruct over MapStruct-Plus's mappers) and fails on the file it already wrote;
// javac doesn't, so Maven builds the module.
tracing::info!("Annotation processing failed in JDT; building with Maven");
delegated = true;
external_build = Some(project.lsp.java_external_build(&module, cancel).await?);
info = project.lsp.java_launch(&module, test, false, false, true, cancel).await?;
}
if delegated {
// Maven runs the annotation processors itself; JDT's failure to rerun one is no compile error.
drop_processor_recreate_errors(&mut info);
}
}
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
@@ -338,10 +354,60 @@ pub(crate) fn register(d: &mut Dispatcher) {
});
}
/// An annotation processor's error for writing a file this build already wrote (JDT's
/// `IdeFilerImpl`: "Source file already created", javac's Filer: "Attempt to recreate a file").
fn processor_recreated_a_file(message: &str) -> bool {
message.contains("FilerException")
&& (message.contains("Source file already created") || message.contains("Attempt to recreate a file"))
}
fn drop_processor_recreate_errors(info: &mut JavaLaunchInfo) {
let before = info.errors.len();
info.errors.retain(|e| !processor_recreated_a_file(&e.message));
info.error_count = info.error_count.saturating_sub((before - info.errors.len()) as u32);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_a_processor_recreating_its_output() {
assert!(processor_recreated_a_file(
"Internal error in the mapping processor: java.lang.RuntimeException: \
javax.annotation.processing.FilerException: Source file already created: /m/target/generated-sources/annotations/a/AMapper__4Impl.java"
));
assert!(processor_recreated_a_file(
"javax.annotation.processing.FilerException: Attempt to recreate a file for type a.B"
));
assert!(!processor_recreated_a_file("Source file already created"));
assert!(!processor_recreated_a_file("cannot find symbol"));
}
#[test]
fn a_delegated_launch_ignores_jdt_processor_recreate_errors() {
let error = |message: &str| ide_lsp::jdtls::JavaCompileError {
path: "src/main/java/a/A.java".into(),
line: 1,
column: 1,
message: message.into(),
};
let mut info = JavaLaunchInfo {
error_count: 2,
errors: vec![
error(
"javax.annotation.processing.FilerException: Source file already created: /m/AMapper__1Impl.java",
),
error("cannot find symbol"),
],
..Default::default()
};
drop_processor_recreate_errors(&mut info);
assert_eq!(info.error_count, 1);
assert_eq!(info.errors.len(), 1);
assert_eq!(info.errors[0].message, "cannot find symbol");
}
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
+94 -3
Переглянути файл
@@ -47,6 +47,48 @@ pub const SECRET_REQUEST: &str = "plugin_secret";
pub const SECRET_WRITE: &str = "plugin_secret_write";
/// OAuth listeners bound on the desktop, never the project server.
pub const AUTH_REQUEST: &str = "plugin_auth";
/// What [`RemotePlugins::register`] adds to a server's commands.
pub const COMMANDS: &[&str] = &[
SYNC_PACKAGES,
"plugin_run_command",
"plugin_set_ui_locale",
"plugin_select_tests",
"plugin_parse_test_results",
"plugin_terminal_session_close",
"plugin_terminal_session_spawn",
"plugin_prepare_debug_run",
"plugin_finish_operation",
"plugin_content_tab_action",
"plugin_post_events",
"plugin_event_kinds",
"plugin_status_items",
"plugin_context_keys",
"plugin_cancel_task",
"plugin_answer_prompt",
"plugin_choose_program",
"plugin_program",
"plugin_answer_command",
"plugin_answer_commit",
"plugin_commit_checks",
"mcp_set_open_editors",
"mcp_set_focused_editor",
"mcp_report_problems",
"plugin_decoration_sets",
"plugin_set_active_file",
"plugin_view_render",
"plugin_view_event",
"plugin_service_detail",
"plugin_service_log",
"plugin_navigation_files",
"plugin_navigation_invalidate",
"plugin_navigation_index",
"plugin_navigation_goto",
"plugin_navigation_markers",
"plugin_endpoints",
"plugin_run_targets",
"plugin_answer_run_target",
"plugin_line_markers",
];
#[derive(Clone, Serialize, Deserialize)]
pub struct SecretWrite {
@@ -134,8 +176,13 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
}
/// Whether a plugin event means something to a remote window (the desktop
/// owns the plugin list, themes and consent).
/// owns the plugin list, themes and consent). The desktop checks it again on
/// arrival: a server may send anything, and only what acts on the remote project
/// passes. A command may only be another plugin command, which runs on the server.
pub fn forwarded(event: &PluginEvent) -> bool {
if let PluginEvent::ExecuteCommand { id, .. } = event {
return plugin_command(id);
}
matches!(
event,
PluginEvent::RunTargetsChanged
@@ -147,7 +194,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::WindowStatusItemChanged { .. }
| PluginEvent::ExecuteCommand { .. }
| PluginEvent::OutputChanged { .. }
| PluginEvent::RevealOutput { .. }
| PluginEvent::ContextChanged { .. }
@@ -163,7 +209,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenLogTab { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::PromptWaiting { .. }
@@ -173,6 +218,13 @@ pub fn forwarded(event: &PluginEvent) -> bool {
)
}
/// `plugin.<plugin id>.<command id>`, as the workbench names plugin commands.
fn plugin_command(id: &str) -> bool {
id.strip_prefix("plugin.")
.and_then(|rest| rest.split_once('.'))
.is_some_and(|(plugin, command)| !plugin.is_empty() && !command.is_empty() && !command.ends_with('.'))
}
// ---- desktop side -----------------------------------------------------------
impl PluginHost {
@@ -951,4 +1003,43 @@ mod tests {
assert!(valid_id("ai-completion"));
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
}
fn execute(id: &str) -> PluginEvent {
PluginEvent::ExecuteCommand {
request_id: "1".into(),
id: id.into(),
arg: Some(r"\\host.example\share\pkg".into()),
chain: vec!["tools".into()],
}
}
#[test]
fn a_server_runs_only_plugin_commands_in_the_desktop_window() {
assert!(forwarded(&execute("plugin.tools.build")));
assert!(forwarded(&execute("plugin.acme.tools.build.all")));
for id in [
"plugins.installFromDisk",
"project.revokeTrust",
"project.removeTrustedLocation",
"ssh.startSession",
"file.openProjectPath",
"terminal.runCommand",
"plugin.",
"plugin.tools",
"plugin..build",
"plugin.tools.",
] {
assert!(!forwarded(&execute(id)), "{id}");
}
}
#[test]
fn a_server_does_not_open_desktop_folders() {
let open = PluginEvent::OpenProject {
plugin_id: "tools".into(),
path: r"\\host.example\share\project".into(),
new_window: true,
};
assert!(!forwarded(&open));
}
}
+103
Переглянути файл
@@ -298,6 +298,56 @@ pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core
Arc::new(PeerSink { peer, window })
}
/// Whether a desktop shows an `event` notification of its server in the window. A server reports
/// on its project; what this machine answers or owns itself (MCP approvals, app updates, window
/// groups) never comes from it.
pub fn client_accepts(event: &IdeEvent) -> bool {
match event {
IdeEvent::McpRequest { .. }
| IdeEvent::McpRequestClosed { .. }
| IdeEvent::WindowGroupsChanged
| IdeEvent::WindowFullscreenChanged
| IdeEvent::UpdateStatusChanged { .. }
| IdeEvent::UpdateAvailable { .. } => false,
IdeEvent::DbSourcesChanged
| IdeEvent::ProjectTrustChanged { .. }
| IdeEvent::FsChanged { .. }
| IdeEvent::ExcludedDirsChanged { .. }
| IdeEvent::DocPatched { .. }
| IdeEvent::DocDirty { .. }
| IdeEvent::WorkspaceEditApplied { .. }
| IdeEvent::DocLanguage { .. }
| IdeEvent::Diagnostics { .. }
| IdeEvent::Task { .. }
| IdeEvent::IndexState { .. }
| IdeEvent::VcsChanged { .. }
| IdeEvent::LanguageServer { .. }
| IdeEvent::LanguageServerWarning { .. }
| IdeEvent::LanguageServerRemoved { .. }
| IdeEvent::LspRefresh { .. }
| IdeEvent::TerminalExited { .. }
| IdeEvent::RunExited { .. }
| IdeEvent::Debug { .. }
| IdeEvent::Notify { .. }
| IdeEvent::SettingsChanged { .. }
| IdeEvent::ProjectRootsChanged { .. }
| IdeEvent::Askpass { .. }
| IdeEvent::AskpassDone { .. }
| IdeEvent::NotebookKernel { .. } => true,
}
}
/// The commands a desktop routes to its server: what `openidea-server` of this build answers,
/// fixed here rather than taken from the server, so a server cannot move a command to this machine.
pub fn client_commands() -> Vec<&'static str> {
let mut out = Dispatcher::standard().commands();
out.extend(crate::plugins::remote_host::COMMANDS);
out.push(crate::plugins::remote::SYNC_COMMAND);
out.sort_unstable();
out.dedup();
out
}
struct PeerSink {
peer: Arc<OnceLock<Peer>>,
window: Option<String>,
@@ -364,3 +414,56 @@ pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
other => other.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::{AskpassKind, McpUiRequest, NotifyLevel};
#[test]
fn a_server_cannot_raise_desktop_prompts() {
let confirm = McpUiRequest::Confirm {
client: "agent".into(),
tool: "run".into(),
subject: "rm -rf".into(),
project: "/srv/app".into(),
detail: None,
};
let update = ide_core::updates::UpdateInfo {
version: "9.9.9".into(),
current_version: "0.1.0".into(),
notes: None,
date: None,
};
for event in [
IdeEvent::McpRequest { id: 1, request: confirm },
IdeEvent::McpRequestClosed { id: 1 },
IdeEvent::UpdateAvailable { update },
IdeEvent::UpdateStatusChanged {
status: ide_core::updates::UpdateStatus { last_checked: None, checking: true },
},
IdeEvent::WindowGroupsChanged,
IdeEvent::WindowFullscreenChanged,
] {
assert!(!client_accepts(&event), "{event:?}");
}
}
#[test]
fn a_server_reports_its_project_to_the_desktop() {
for event in [
IdeEvent::FsChanged {
created: vec!["/srv/app/a".into()],
modified: vec![],
removed: vec![],
ignore_rules: vec![],
},
IdeEvent::VcsChanged { root: "/srv/app".into() },
IdeEvent::Notify { level: NotifyLevel::Info, title: "Build".into(), body: None },
IdeEvent::Askpass { id: 1, prompt: "Password".into(), kind: AskpassKind::Secret, remember: false },
IdeEvent::ProjectTrustChanged { root: "/srv/app".into(), trusted: true },
] {
assert!(client_accepts(&event), "{event:?}");
}
}
}
+57 -2
Переглянути файл
@@ -18,6 +18,15 @@ pub struct TrustStore {
state: Mutex<IdeResult<TrustDecisions>>,
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
inherited: Mutex<Vec<PathBuf>>,
remote_file: PathBuf,
remote: Mutex<IdeResult<RemoteDecisions>>,
}
/// This machine's decisions about remote projects, by location (see [`TrustStore::remote_decision`]).
#[derive(Clone, Default, Serialize, Deserialize)]
struct RemoteDecisions {
trusted: Vec<String>,
restricted: Vec<String>,
}
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
@@ -40,7 +49,37 @@ impl TrustStore {
}
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
let remote_file = data.join("remote-project-trust.json");
let remote = match std::fs::read(&remote_file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemoteDecisions::default()),
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default(), remote_file, remote: Mutex::new(remote) }
}
/// The decision made on this machine for the remote project at `location` (`None`: none yet).
/// Commands a remote window runs here follow it, not the server's own trust.
pub fn remote_decision(&self, location: &str) -> IdeResult<Option<bool>> {
let remote = self.remote.lock().clone()?;
Ok(if remote.restricted.iter().any(|l| l == location) {
Some(false)
} else if remote.trusted.iter().any(|l| l == location) {
Some(true)
} else {
None
})
}
pub fn decide_remote(&self, location: &str, trusted: bool) -> IdeResult<()> {
let mut state = self.remote.lock();
let mut next = state.clone()?;
next.trusted.retain(|l| l != location);
next.restricted.retain(|l| l != location);
if trusted { &mut next.trusted } else { &mut next.restricted }.push(location.to_owned());
persist(&self.remote_file, &next)?;
*state = Ok(next);
Ok(())
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
@@ -133,7 +172,7 @@ impl TrustStore {
}
}
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
@@ -372,6 +411,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_trust_status"
| "remote_trust_set"
| "remote_bootstrap_list"
| "wsl_distros"
| "wsl_resolve"
@@ -526,4 +567,18 @@ mod tests {
fn restricted_mode_allows_clipboard_write_text() {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
#[test]
fn remote_project_decisions_are_kept_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
let load = || super::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
let store = load();
let here = r#"[{"kind":"ssh","config":"box"},"/srv/app"]"#;
assert_eq!(store.remote_decision(here).unwrap(), None);
store.decide_remote(here, true).unwrap();
assert_eq!(store.remote_decision(here).unwrap(), Some(true));
assert_eq!(store.remote_decision(r#"[{"kind":"ssh","config":"box"},"/srv/other"]"#).unwrap(), None);
store.decide_remote(here, false).unwrap();
assert_eq!(load().remote_decision(here).unwrap(), Some(false));
}
}
+26
Переглянути файл
@@ -455,3 +455,29 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
// As `openidea-server` assembles its commands.
let plugins = ide_host::plugins::remote_host::RemotePlugins::start(&base.join("data"), &host, peer_slot).unwrap();
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
dispatcher.add(ide_host::plugins::remote::SYNC_COMMAND, |_| async { Ok(()) });
assert_eq!(rpc::client_commands(), dispatcher.commands());
assert!(rpc::client_commands().contains(&"term_spawn"));
plugins.host().shutdown();
}
+3
Переглянути файл
@@ -21,6 +21,9 @@ ignore.workspace = true
globset.workspace = true
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
[build-dependencies]
blake3.workspace = true
[dev-dependencies]
tempfile.workspace = true
criterion = { version = "0.5", default-features = false }
+33
Переглянути файл
@@ -0,0 +1,33 @@
//! Hashes this crate's sources into `INDEX_SOURCE_HASH`: what is extracted and stored for a
//! file (ignored state, size limit, record layout) is decided here as well as in
//! `ide-language`, so the persistent index is rebuilt when either changes.
use std::path::{Path, PathBuf};
fn main() {
let src = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src");
println!("cargo:rerun-if-changed={}", src.display());
let mut files = Vec::new();
collect(&src, &mut files);
files.sort();
let mut h = blake3::Hasher::new();
for file in files {
h.update(file.strip_prefix(&src).unwrap_or(&file).to_string_lossy().replace('\\', "/").as_bytes());
h.update(b"\0");
h.update(&std::fs::read(&file).unwrap());
h.update(b"\0");
}
println!("cargo:rustc-env=INDEX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else {
out.push(path);
}
}
}
+1 -1
Переглянути файл
@@ -45,7 +45,7 @@ impl Store {
}
};
let store = Self { db: RwLock::new(db) };
let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display());
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
+3
Переглянути файл
@@ -58,6 +58,9 @@ tree-sitter-graphql = "0.3.0"
tree-sitter-prisma-io = "1.6.0"
tree-sitter-gdscript = "6.1.0"
[build-dependencies]
blake3.workspace = true
[dev-dependencies]
criterion = { version = "0.5", default-features = false }
tempfile.workspace = true
+61
Переглянути файл
@@ -0,0 +1,61 @@
//! Hashes everything that decides what `Syntax::tags` extracts (this crate's
//! sources, queries, language definitions and the locked tree-sitter crates)
//! into `SYNTAX_SOURCE_HASH`, so persistent caches survive releases that leave
//! extraction untouched.
use std::path::{Path, PathBuf};
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let workspace = manifest.join("../..");
let lock = workspace.join("Cargo.lock");
let inputs = [manifest.join("src"), manifest.join("queries"), workspace.join("languages")];
let mut h = blake3::Hasher::new();
for dir in &inputs {
println!("cargo:rerun-if-changed={}", dir.display());
let mut files = Vec::new();
collect(dir, &mut files);
files.sort();
for file in files {
let rel = file.strip_prefix(&workspace).unwrap_or(&file);
h.update(rel.to_string_lossy().replace('\\', "/").as_bytes());
h.update(b"\0");
h.update(&std::fs::read(&file).unwrap());
h.update(b"\0");
}
}
println!("cargo:rerun-if-changed={}", lock.display());
match std::fs::read_to_string(&lock) {
Ok(text) => h.update(tree_sitter_packages(&text).as_bytes()),
// Built outside the workspace: fall back to the crate version.
Err(_) => h.update(std::env::var("CARGO_PKG_VERSION").unwrap().as_bytes()),
};
println!("cargo:rustc-env=SYNTAX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else {
out.push(path);
}
}
}
/// `name version checksum` of every locked `tree-sitter*` package.
fn tree_sitter_packages(lock: &str) -> String {
let mut out = String::new();
for package in lock.split("[[package]]") {
let field = |key: &str| {
package.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix(" = ")).unwrap_or("").trim_matches('"')
};
if field("name").starts_with("tree-sitter") {
out.push_str(&format!("{} {} {}\n", field("name"), field("version"), field("checksum")));
}
}
out
}
+4 -22
Переглянути файл
@@ -132,29 +132,11 @@ impl Syntax {
SPECS.iter().map(|s| s.name)
}
/// Changes whenever extraction output may change (grammar set, queries,
/// crate version); persistent caches of [`Syntax::tags`] key on it.
/// Changes whenever extraction output may change (this crate's sources,
/// queries, language definitions, tree-sitter crate versions; see
/// `build.rs`); persistent caches of [`Syntax::tags`] key on it.
pub fn fingerprint(&self) -> String {
static FP: OnceLock<String> = OnceLock::new();
FP.get_or_init(|| {
let mut h = blake3::Hasher::new();
h.update(env!("CARGO_PKG_VERSION").as_bytes());
// Bump when web definitions (`.class`, `#id`, `--var` tags) change.
h.update(b"web-definitions-1");
for s in SPECS {
h.update(s.name.as_bytes());
h.update(&Language::new(s.language).abi_version().to_le_bytes());
h.update(s.outline.as_bytes());
for list in [s.fold_nodes, s.fold_bodies, s.imports, s.constructors] {
for k in list {
h.update(k.as_bytes());
h.update(b"\0");
}
}
}
h.finalize().to_hex()[..16].to_owned()
})
.clone()
env!("SYNTAX_SOURCE_HASH").to_owned()
}
/// Whether a tree-sitter grammar is bundled for this language.
+23
Переглянути файл
@@ -74,3 +74,26 @@ test("⌘K commits the checked changes with the typed message", async ({ workben
expect(await ipcCalls(page, "vcs_stage")).toHaveLength(0);
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
});
test("commit message history opens above the message without an empty icon column", async ({ workbench: page }) => {
await stubRepository(page);
await page.keyboard.press("Meta+k");
const message = page.getByRole("textbox", { name: "Commit message" });
await expect(message).toBeVisible();
await page.evaluate((root) => {
localStorage.setItem(`openidea.vcs.commitMessages:${root}`, JSON.stringify(["Previous commit message"]));
}, ROOT);
await message.fill("Draft");
await page.getByRole("button", { name: "Commit Message History" }).click();
const menu = page.getByRole("menu", { name: "Recent commit messages" });
const row = menu.getByRole("menuitem", { name: "Previous commit message" });
await expect(row).toBeVisible();
const messageBox = await message.boundingBox();
const menuBox = await menu.locator("..").boundingBox();
expect(messageBox).not.toBeNull();
expect(menuBox).not.toBeNull();
expect(menuBox!.y + menuBox!.height).toBeLessThan(messageBox!.y);
expect(Math.abs(menuBox!.x - messageBox!.x)).toBeLessThan(2);
await expect(row.locator("span")).toHaveCount(1);
});
+2
Переглянути файл
@@ -699,6 +699,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
crate::remote_dev::remote_bootstrap_list,
crate::remote_dev::remote_connect,
crate::remote_dev::remote_current,
crate::remote_dev::remote_trust_status,
crate::remote_dev::remote_trust_set,
crate::remote_dev::remote_disconnect,
crate::remote_dev::remote_forwarded_ports,
crate::remote_dev::remote_forward_port,
+6 -24
Переглянути файл
@@ -162,30 +162,12 @@ pub fn run() {
let webview = request.message.webview_ref();
if let Some(state) = webview.try_state::<AppState>() {
let label = AppState::owner_window(webview.label());
if let Some(connection) = state.remote_dev.get(label) {
let label = label.to_owned();
let command = request.message.command().to_owned();
let invoke = invoke.clone();
let app = webview.app_handle().clone();
tauri::async_runtime::spawn(async move {
match connection.authorize_command(&label, &command).await {
Ok(()) => {
let resolver = request.resolver.clone();
// Sync commands must run on the main thread, as they do in local windows.
if let Err(error) = app.run_on_main_thread(move || {
invoke(request);
}) {
resolver.reject(ide_core::IdeError::internal(format!(
"failed to dispatch command on the main thread: {error}"
)));
}
}
Err(error) => request.resolver.reject(error),
}
});
return true;
}
if let Err(error) = state.host.authorize_command(webview.label(), request.message.command()) {
let command = request.message.command();
let authorized = match state.remote_dev.get(label) {
Some(connection) => connection.authorize_command(&state.host.trust, command),
None => state.host.authorize_command(webview.label(), command),
};
if let Err(error) = authorized {
request.resolver.reject(error);
return true;
}
+140 -19
Переглянути файл
@@ -104,25 +104,35 @@ type Channels = Arc<Mutex<HashMap<u64, (Channel<ChannelFrame>, u64)>>>;
pub struct Connections(Mutex<HashMap<String, Arc<Connection>>>);
impl Connection {
pub async fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
if ide_host::trust::restricted_command_allowed(command) {
return Ok(());
}
let project: Option<ide_host::ops::project::ProjectInfo> = self
.peer
.request(
"invoke",
Invoke { window: window.to_owned(), cmd: "project_current".into(), args: serde_json::json!({}) },
None,
)
.await
.map_err(ide_host::rpc::error_from_reply)?;
if project.is_some_and(|p| p.trusted) {
/// Commands of a remote window that run on this machine follow this machine's decision about the
/// remote project, never the server's: the server only answers for what runs there.
pub fn authorize_command(&self, trust: &ide_host::trust::TrustStore, command: &str) -> IdeResult<()> {
if ide_host::trust::restricted_command_allowed(command) || self.trusted_here(trust) == Some(true) {
Ok(())
} else {
Err(IdeError::unavailable("Project is in Restricted Mode"))
}
}
/// This machine's decision about the project (`None`: none yet). A dev container is its local
/// folder's project, trusted before it was started.
pub fn trusted_here(&self, trust: &ide_host::trust::TrustStore) -> Option<bool> {
match trust.remote_decision(&self.location()) {
Ok(Some(decision)) => Some(decision),
Ok(None) => match &self.target {
RemoteTarget::DevContainer { folder, .. } => {
trust.trusted(std::path::Path::new(folder)).unwrap_or(false).then_some(true)
}
_ => None,
},
Err(_) => Some(false),
}
}
/// The key of this project's decisions on this machine.
fn location(&self) -> String {
serde_json::to_string(&(&self.target, &self.root)).unwrap_or_default()
}
}
impl Connections {
@@ -197,7 +207,7 @@ pub struct RemoteProject {
pub host: String,
pub server_version: String,
pub os: String,
/// Commands the server answers (the rest run locally).
/// Commands routed to the server (the rest run locally).
pub commands: Vec<String>,
pub project: ide_host::ops::project::ProjectInfo,
}
@@ -212,11 +222,12 @@ impl Handler for ClientHandler {
fn on_notification(&self, method: &str, params: Value) {
match method {
"event" => match serde_json::from_value::<EventMessage>(params) {
Ok(m) => {
Ok(m) if ide_host::rpc::client_accepts(&m.event) => {
// The server's windows are this window.
let _ = m.window;
TauriSink { app: self.app.clone(), window: Some(self.window.clone()) }.emit(m.event);
}
Ok(_) => {}
Err(e) => tracing::warn!("remote event: {e}"),
},
// Plugin code of this window's project runs on the server.
@@ -327,6 +338,12 @@ pub(crate) fn platform(uname: &str) -> (String, String) {
(os, arch)
}
/// An absolute path on a server (Linux or macOS): never one this machine would read as a network
/// share or a device (`//host/share`, `\\host\share`, `//./pipe`).
fn server_path(path: &str) -> bool {
path.starts_with('/') && !path.starts_with("//") && !path.contains('\\')
}
/// What [`local_server`] settled on.
pub(crate) enum LocalServer {
/// A server binary on this machine and its [`build_id`].
@@ -519,8 +536,7 @@ pub async fn remote_connect(
ide_host::rpc::PROTOCOL_VERSION
)));
}
let commands: Vec<String> =
peer.request("commands", Value::Null, None).await.map_err(ide_host::rpc::error_from_reply)?;
let commands: Vec<String> = ide_host::rpc::client_commands().into_iter().map(str::to_owned).collect();
let root = if path.trim().is_empty() || path.trim() == "~" {
info.home.clone()
} else if let Some(rest) = path.trim().strip_prefix("~/") {
@@ -534,6 +550,9 @@ pub async fn remote_connect(
peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?;
// As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's.
let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root);
if !server_path(&root) {
return Err(IdeError::invalid(format!("openidea-server opened an unexpected project folder: {root}")));
}
let _ = window.set_title(&format!("{} [{title_tag}]", project.display_name));
let connection = Arc::new(Connection {
peer,
@@ -674,6 +693,27 @@ pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option<Remo
})
}
/// This machine's trust decision for the window's remote project (see [`Connection::authorize_command`]).
#[tauri::command]
#[specta::specta]
pub fn remote_trust_status(window: Window, state: State<'_, AppState>) -> IdeResult<ide_host::trust::TrustStatus> {
let c = connection(&window, &state)?;
let decision = c.trusted_here(&state.host.trust);
Ok(ide_host::trust::TrustStatus {
root: c.root.clone().into(),
trusted: decision == Some(true),
decided: decision.is_some(),
})
}
/// Records the user's trust decision for the window's remote project on this machine.
#[tauri::command]
#[specta::specta]
pub fn remote_trust_set(window: Window, state: State<'_, AppState>, trusted: bool) -> IdeResult<()> {
let c = connection(&window, &state)?;
state.host.trust.decide_remote(&c.location(), trusted)
}
/// A port of the remote host reachable on this machine at `localhost:local_port`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -925,7 +965,7 @@ pub async fn remote_import_local(
let Some(session) = c.session.clone() else {
return Err(IdeError::unavailable("Importing local files is not supported in a WSL project yet"));
};
c.authorize_command(label, "fs_copy").await?;
c.authorize_command(&state.host.trust, "fs_copy")?;
let cancel = state.host.begin_request(request);
let result = async {
let fs = ide_ssh::RemoteFs::new(session.sftp().await?);
@@ -971,6 +1011,87 @@ mod tests {
peer.kill();
}
/// A server that reports its project as trusted.
struct TrustingServer;
impl Handler for TrustingServer {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
Ok(serde_json::to_value(ide_host::ops::project::ProjectInfo {
trusted: true,
root: "/srv/app".into(),
name: "app".into(),
display_name: "app".into(),
is_git: false,
settings: Default::default(),
workspace: None,
unity: None,
})
.unwrap())
}
}
fn connected(target: RemoteTarget, root: &str) -> Connection {
let (client, server) = tokio::io::duplex(1 << 16);
let (reader, writer) = tokio::io::split(client);
let peer = Peer::connect(reader, writer, Arc::new(ide_jsonrpc::NoopHandler));
let (reader, writer) = tokio::io::split(server);
std::mem::forget(Peer::connect(reader, writer, Arc::new(TrustingServer)));
Connection {
peer,
target,
root: root.into(),
host: "host".into(),
os: "linux".into(),
commands: Vec::new(),
channels: Arc::default(),
next_channel: std::sync::atomic::AtomicU64::new(1),
session: None,
exec: None,
forwards: Mutex::new(Vec::new()),
web_forwards: Mutex::new(Vec::new()),
}
}
#[tokio::test]
async fn the_server_does_not_decide_trust_for_commands_run_here() {
let tmp = tempfile::tempdir().unwrap();
let trust = ide_host::trust::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
let c = connected(RemoteTarget::Ssh { config: "box".into() }, "/srv/app");
let error = c.authorize_command(&trust, "plugin_grant").unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(c.authorize_command(&trust, "fs_read_text").is_ok());
trust.decide_remote(&c.location(), true).unwrap();
assert!(c.authorize_command(&trust, "plugin_grant").is_ok());
let other = connected(RemoteTarget::Ssh { config: "box".into() }, "/srv/other");
assert!(other.authorize_command(&trust, "plugin_grant").is_err());
trust.decide_remote(&c.location(), false).unwrap();
assert!(c.authorize_command(&trust, "plugin_grant").is_err());
}
#[tokio::test]
async fn a_dev_container_follows_its_local_folder() {
let tmp = tempfile::tempdir().unwrap();
let folder = tmp.path().join("app");
std::fs::create_dir(&folder).unwrap();
let trust = ide_host::trust::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
let target =
RemoteTarget::DevContainer { folder: folder.to_string_lossy().into_owned(), config: String::new() };
let c = connected(target, "/workspaces/app");
assert_eq!(c.trusted_here(&trust), None);
trust.decide(&folder, true, false).unwrap();
assert_eq!(c.trusted_here(&trust), Some(true));
assert!(c.authorize_command(&trust, "plugin_grant").is_ok());
}
#[test]
fn a_server_project_folder_is_never_a_share_or_device_here() {
assert!(server_path("/home/me/app"));
for path in [r"\\host\share", "//host/share", "//./pipe/x", "//?/UNC/host/share", "home/me", r"/home/a\b", ""] {
assert!(!server_path(path), "{path}");
}
}
#[test]
fn maps_uname_to_rust_platform_names() {
assert_eq!(platform("Linux x86_64"), ("linux".into(), "x86_64".into()));
+6 -2
Переглянути файл
@@ -1634,6 +1634,10 @@ export const commands = {
os: string,
commands: string[],
} | null>("remote_current"),
/** This machine's trust decision for the window's remote project (see [`Connection::authorize_command`]). */
remoteTrustStatus: () => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("remote_trust_status")),
/** Records the user's trust decision for the window's remote project on this machine. */
remoteTrustSet: (trusted: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("remote_trust_set", { trusted })),
remoteDisconnect: () => __TAURI_INVOKE<void>("remote_disconnect"),
/** The remote ports this window forwards to this machine. */
remoteForwardedPorts: () => __TAURI_INVOKE<ForwardedPort[]>("remote_forwarded_ports"),
@@ -8720,7 +8724,7 @@ export type RemoteProject_Deserialize = {
host: string,
serverVersion: string,
os: string,
/** Commands the server answers (the rest run locally). */
/** Commands routed to the server (the rest run locally). */
commands: string[],
project: ProjectInfo_Deserialize,
};
@@ -8732,7 +8736,7 @@ export type RemoteProject_Serialize = {
host: string,
serverVersion: string,
os: string,
/** Commands the server answers (the rest run locally). */
/** Commands routed to the server (the rest run locally). */
commands: string[],
project: ProjectInfo_Serialize,
};
+19
Переглянути файл
@@ -0,0 +1,19 @@
.value {
margin-top: var(--space-2);
padding: var(--space-2);
max-height: 160px;
overflow: auto;
font-family: var(--font-mono);
font-size: var(--fs-small);
color: var(--fg-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: var(--bg-input);
border: 1px solid var(--border-strong);
border-radius: var(--radius-small);
}
.note {
margin-top: var(--space-2);
color: var(--fg-muted);
}
+58
Переглянути файл
@@ -0,0 +1,58 @@
/**
* Plugin code of a remote window runs on its server. Opening a link or writing
* the clipboard on this machine waits for the user, who sees exactly what.
*/
import { tx } from "@/platform/i18n";
import { ask } from "@/workbench/messages";
import { usePlugins } from "./store";
import styles from "./remoteAsk.module.css";
const PREVIEW_CHARS = 500;
const PREVIEW_LINES = 12;
const pluginName = (pluginId: string) => usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId;
/** Whether the user lets a remote plugin open `url` with this machine's browser. */
export async function confirmRemoteOpen(pluginId: string, url: string): Promise<boolean> {
const name = pluginName(pluginId);
const r = await ask({
title: tx`Open a link from ${name}?`,
body: (
<>
<div>{tx("A plugin running on the remote host wants to open this address on this computer:")}</div>
<div className={styles.value}>{url}</div>
</>
),
buttons: [{ id: "open", label: tx("Open"), primary: true }],
});
return r.button === "open";
}
/** Whether the user lets a remote plugin put `text` on this machine's clipboard. */
export async function confirmRemoteCopy(pluginId: string, text: string): Promise<boolean> {
const name = pluginName(pluginId);
const preview = previewOf(text);
const count = text.length.toLocaleString();
const r = await ask({
title: tx`Copy text from ${name}?`,
body: (
<>
<div>
{tx("A plugin running on the remote host wants to copy this text to the clipboard of this computer:")}
</div>
<div className={styles.value}>{preview}</div>
{preview !== text && <div className={styles.note}>{tx`${count} characters in total`}</div>}
</>
),
buttons: [{ id: "copy", label: tx("Copy"), primary: true }],
});
return r.button === "copy";
}
/** The start of `text`, at most `PREVIEW_CHARS` characters on `PREVIEW_LINES` lines, marked when cut. */
function previewOf(text: string): string {
let preview = text.slice(0, PREVIEW_CHARS);
const lines = preview.split("\n");
if (lines.length > PREVIEW_LINES) preview = lines.slice(0, PREVIEW_LINES).join("\n");
return preview.length < text.length ? `${preview}…` : text;
}
+93
Переглянути файл
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import type { RemoteSession } from "@/bindings";
import { useRemote } from "@/platform/remote";
import { MessagesHost } from "@/workbench/messages";
import { handleRuntimeEvent } from "./runtimeUi";
const URL_FROM_SERVER = "https://example.com/login?state=abc&redirect=https%3A%2F%2Fexample.org%2Fcallback";
const remote: RemoteSession = {
target: { kind: "ssh", config: "box" },
root: "/srv/app",
host: "box",
os: "linux",
commands: [],
};
describe("links and clipboard writes from a remote plugin", () => {
let open: ReturnType<typeof vi.spyOn>;
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
open = vi.spyOn(window, "open").mockImplementation(() => null);
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
useRemote.setState({ session: remote });
render(<MessagesHost />);
});
afterEach(() => {
open.mockRestore();
useRemote.setState({ session: null });
});
it("opens a link only after the user confirms its full address", async () => {
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: URL_FROM_SERVER }));
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(URL_FROM_SERVER);
expect(open).not.toHaveBeenCalled();
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Open" })));
expect(open).toHaveBeenCalledWith(URL_FROM_SERVER, "_blank", "noopener");
});
it("opens nothing when the user cancels", async () => {
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: URL_FROM_SERVER }));
const dialog = await screen.findByRole("dialog");
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })));
expect(screen.queryByRole("dialog")).toBeNull();
expect(open).not.toHaveBeenCalled();
});
it("does not ask about addresses it would never open", async () => {
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: "file:///etc/passwd" }));
await act(async () => {});
expect(screen.queryByRole("dialog")).toBeNull();
expect(open).not.toHaveBeenCalled();
});
it("copies only after the user confirms the text", async () => {
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "token-123" }));
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("token-123");
expect(writeText).not.toHaveBeenCalled();
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Copy" })));
expect(writeText).toHaveBeenCalledWith("token-123");
});
it("copies nothing when the user cancels", async () => {
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "token-123" }));
const dialog = await screen.findByRole("dialog");
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })));
expect(screen.queryByRole("dialog")).toBeNull();
expect(writeText).not.toHaveBeenCalled();
});
it("previews long text shortened, and copies all of it", async () => {
const text = `${"a".repeat(2000)}TAIL`;
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text }));
const dialog = await screen.findByRole("dialog");
expect(dialog).not.toHaveTextContent("TAIL");
expect(dialog).toHaveTextContent(/2,?004 characters in total/);
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Copy" })));
expect(writeText).toHaveBeenCalledWith(text);
});
it("acts at once for plugins running on this machine", () => {
useRemote.setState({ session: null });
handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "local" });
handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: "https://example.com/" });
expect(writeText).toHaveBeenCalledWith("local");
expect(open).toHaveBeenCalledWith("https://example.com/", "_blank", "noopener");
expect(screen.queryByRole("dialog")).toBeNull();
});
});
+17 -3
Переглянути файл
@@ -33,9 +33,10 @@ import { editorsFor, onEditorEvent } from "@/editor/api";
import { executeAction, executeActionOrThrow, getAction, onActionExecuted } from "@/platform/actions";
import { clearContext, setContext, type ContextValue } from "@/platform/context";
import { isTauri, onIdeEvent } from "@/platform/ipc";
import { openExternalUrl } from "@/platform/openUrl";
import { isOpenable, openExternalUrl } from "@/platform/openUrl";
import { tx, VerbatimTextContext } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { ipcPath } from "@/platform/paths";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
@@ -46,6 +47,7 @@ import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { editorContext } from "./editorContext";
import { pluginsBackend } from "./backend";
import { askNetPermission } from "./netAsk";
import { confirmRemoteCopy, confirmRemoteOpen } from "./remoteAsk";
import { usePluginT } from "./pluginText";
import { setPluginOutput } from "./views/outputStore";
import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor";
@@ -605,13 +607,15 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
return true;
case "copyToClipboard":
void writeClipboardText(e.text);
if (isRemoteWindow()) void copyRemoteText(e.pluginId, e.text);
else void writeClipboardText(e.text);
return true;
case "openProject":
void executeAction("file.openProjectPath", { path: e.path, newWindow: e.newWindow });
return true;
case "openUrl":
void openExternalUrl(e.url);
if (isRemoteWindow()) void openRemoteUrl(e.pluginId, e.url);
else void openExternalUrl(e.url);
return true;
case "promptRequested":
void answerPrompt(e.promptId, e.prompt, e.pluginId);
@@ -634,6 +638,16 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
}
}
/** A remote window's plugin code runs on its server: what it opens here, the user confirms first. */
async function openRemoteUrl(pluginId: string, url: string) {
if (isOpenable(url) && (await confirmRemoteOpen(pluginId, url))) await openExternalUrl(url);
}
/** A remote window's plugin code runs on its server: what it copies here, the user confirms first. */
async function copyRemoteText(pluginId: string, text: string) {
if (await confirmRemoteCopy(pluginId, text)) await writeClipboardText(text).catch(() => {});
}
/** Another window ran a command of a plugin whose prompt is open here: show this window. */
function bringWindowToFront() {
if (!isTauri()) return;
+11 -2
Переглянути файл
@@ -3,7 +3,7 @@ import { create } from "zustand";
import { executeAction, registerActions } from "@/platform/actions";
import { call, commands, onIdeEvent, type TrustDecisions, type TrustStatus } from "@/platform/ipc";
import { dirname, useProject } from "@/platform/project";
import { initializeRemoteTrustMigration } from "@/platform/remote";
import { initializeRemoteTrustMigration, isRemoteRoot } from "@/platform/remote";
import { provideProjectTrustPrompt, type ProjectTrustScope } from "@/platform/projectTrust";
import { invokeLocal } from "@/platform/tauriCore";
import { registerOverlay, registerSettingsPage, registerWorkbenchBanner } from "@/workbench/contributions";
@@ -200,6 +200,7 @@ async function decide(trusted: boolean) {
await invokeLocal("project_trust_set", { path, trusted, folder });
} else {
await call(commands.projectTrustSet(path, trusted, folder));
if (isRemoteRoot(path)) await call(commands.remoteTrustSet(trusted));
const project = await commands.projectCurrent();
if (project) useProject.setState({ project, settings: project.settings });
}
@@ -239,6 +240,12 @@ export function activate(): () => void {
const status = await (scope === "local"
? invokeLocal<TrustStatus>("project_trust_status", { path })
: call(commands.projectTrustStatus(path)));
if (scope === "current" && isRemoteRoot(path)) {
// The server's answer covers what runs there; commands run here need this machine's own.
const desktop = await call(commands.remoteTrustStatus());
if (status.decided && desktop.decided && status.trusted === desktop.trusted) return true;
return promptForTrust(status, scope);
}
if (status.decided) return true;
return promptForTrust(status, scope);
}),
@@ -267,7 +274,9 @@ export function activate(): () => void {
category: "Project",
run: async ({ arg }) => {
const path = typeof arg === "string" ? arg : useProject.getState().project?.root;
if (path) await call(commands.projectTrustSet(path, false, false));
if (!path) return;
await call(commands.projectTrustSet(path, false, false));
if (isRemoteRoot(path)) await call(commands.remoteTrustSet(false));
},
},
{
+49 -1
Переглянути файл
@@ -4,6 +4,7 @@ import { confirmProjectTrust } from "@/platform/projectTrust";
import { executeAction } from "@/platform/actions";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useRemote } from "@/platform/remote";
import { invokeLocal } from "@/platform/tauriCore";
import { OverlayHost } from "@/workbench/OverlayHost";
import { useContributions, useSettingsPages } from "@/workbench/contributions";
@@ -29,10 +30,14 @@ vi.mock("@/platform/ipc", async (original) => {
projectTrustSet: (...args: unknown[]) => decide(...args),
projectTrustList: () => listDecisions(),
projectTrustAddLocation: (...args: unknown[]) => addLocation(...args),
remoteTrustStatus: () => remoteStatus(),
remoteTrustSet: (...args: unknown[]) => remoteDecide(...args),
projectCurrent: async () => null,
},
};
});
const remoteStatus = vi.fn();
const remoteDecide = vi.fn();
let dispose: () => void;
beforeEach(() => {
vi.mocked(invokeLocal).mockReset();
@@ -40,13 +45,56 @@ beforeEach(() => {
.mockReset()
.mockImplementation(async (root: string) => ({ status: "ok", data: { root, trusted: false, decided: false } }));
decide.mockReset().mockResolvedValue({ status: "ok", data: null });
remoteStatus
.mockReset()
.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: false, decided: false } });
remoteDecide.mockReset().mockResolvedValue({ status: "ok", data: null });
addLocation.mockReset().mockResolvedValue({ status: "ok", data: null });
listDecisions.mockReset().mockResolvedValue({ status: "ok", data: { projects: [], locations: [], restricted: [] } });
pickFolder.mockReset().mockResolvedValue(null);
useProject.setState({ project: null });
dispose = activate();
});
afterEach(() => dispose());
afterEach(() => {
dispose();
useRemote.setState({ session: null });
});
function remoteSession() {
useRemote.setState({
session: { target: { kind: "ssh", config: "box" }, root: "/srv/app", host: "box", os: "linux", commands: [] },
});
}
it("asks in a remote window when only the server has a trust decision, and records the answer on both", async () => {
remoteSession();
status.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
render(<OverlayHost />);
const opening = confirmProjectTrust("/srv/app");
await screen.findByRole("dialog");
fireEvent.click(screen.getByRole("button", { name: "Preview in Restricted Mode" }));
await act(async () => opening);
expect(decide).toHaveBeenCalledWith("/srv/app", false, false);
expect(remoteDecide).toHaveBeenCalledWith(false);
});
it("reuses a remote project's decision once this machine has the same one", async () => {
remoteSession();
status.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
remoteStatus.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
render(<OverlayHost />);
await act(async () => confirmProjectTrust("/srv/app"));
expect(screen.queryByRole("dialog")).toBeNull();
expect(remoteDecide).not.toHaveBeenCalled();
});
it("revokes a remote project's trust on this machine too", async () => {
remoteSession();
useProject.setState({ project: { root: "/srv/app", name: "app", isGit: false, trusted: true, settings: {} } });
await act(async () => executeAction("project.revokeTrust"));
expect(decide).toHaveBeenCalledWith("/srv/app", false, false);
expect(remoteDecide).toHaveBeenCalledWith(false);
});
it("offers three choices, cancels without a trust decision, and supports restricted preview", async () => {
render(<OverlayHost />);
+5 -5
Переглянути файл
@@ -994,7 +994,7 @@ export function CommitMessageField({
const amend = useCommit((s) => s.amend);
const focusTick = useCommit((s) => s.focusTick);
const ref = useRef<HTMLTextAreaElement>(null);
const historyRef = useRef<HTMLButtonElement>(null);
const barRef = useRef<HTMLDivElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const historyTick = useCommit((s) => s.historyTick);
const checkProblems = useCommit((s) => s.checkProblems);
@@ -1025,10 +1025,9 @@ export function CommitMessageField({
return (
<>
<div className={styles.commitBar}>
<div ref={barRef} className={styles.commitBar}>
<Checkbox checked={amend} onChange={(v) => void setAmend(v)} label="Amend" />
<IconButton
ref={historyRef}
icon="history"
title="Commit Message History"
size="small"
@@ -1040,8 +1039,9 @@ export function CommitMessageField({
<Menu
open={historyOpen}
onClose={() => setHistoryOpen(false)}
anchor={historyRef}
placement="bottom-end"
anchor={barRef}
placement="top-start"
iconColumn={false}
aria-label="Recent commit messages"
items={history.map((m, i) => ({
id: `h${i}`,
+5
Переглянути файл
@@ -1027,6 +1027,11 @@
"\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.",
"Allow once": "Allow once",
"Always allow": "Always allow",
"Open a link from {name}?": "Open a link from {name}?",
"A plugin running on the remote host wants to open this address on this computer:": "A plugin running on the remote host wants to open this address on this computer:",
"Copy text from {name}?": "Copy text from {name}?",
"A plugin running on the remote host wants to copy this text to the clipboard of this computer:": "A plugin running on the remote host wants to copy this text to the clipboard of this computer:",
"{count} characters in total": "{count} characters in total",
"Remove": "Remove",
"Allowed when the plugin asked at first use. Remove it to be asked again.": "Allowed when the plugin asked at first use. Remove it to be asked again.",
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones",
+5
Переглянути файл
@@ -7387,6 +7387,11 @@
"\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "「一直允许」会记住 {host};可随时在插件权限中撤销。",
"Allow once": "这次允许",
"Always allow": "一直允许",
"Open a link from {name}?": "打开来自 {name} 的链接?",
"A plugin running on the remote host wants to open this address on this computer:": "在远程主机上运行的插件想在这台电脑上打开以下地址:",
"Copy text from {name}?": "复制来自 {name} 的文本?",
"A plugin running on the remote host wants to copy this text to the clipboard of this computer:": "在远程主机上运行的插件想将以下文本复制到这台电脑的剪贴板:",
"{count} characters in total": "共 {count} 个字符",
"Allowed when the plugin asked at first use. Remove it to be asked again.": "在插件首次使用询问时允许。移除后下次会再次询问。",
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "首次连接清单之外的主机时询问;你可以逐个允许或拒绝,并可撤销已允许的主机",
"This allows this exact host on any port; subdomains need separate permission.": "允许此确切主机的任意端口;子域名需要单独授权。",
+1 -1
Переглянути файл
@@ -3,7 +3,7 @@ import { isTauri } from "./ipc";
const OPENABLE_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
/** Plugins and rendered content reach this with arbitrary strings: never hand `file:` or app-launching schemes to the OS. */
function isOpenable(url: string): boolean {
export function isOpenable(url: string): boolean {
if (url !== url.trim()) return false;
try {
return OPENABLE_PROTOCOLS.has(new URL(url).protocol);
+10 -1
Переглянути файл
@@ -118,7 +118,10 @@ const route: InvokeRoute = (cmd, args, options) => {
});
};
/** Routes the commands the server answers (`session.commands`) to it from now on. */
/**
* Routes `session.commands` to the server from now on: the commands this build's
* server answers, as the desktop backend knows them (not as the server reports them).
*/
export function enableRemote(session: RemoteSession) {
routed = new Set(session.commands);
useRemote.setState({ session });
@@ -135,6 +138,11 @@ export function isRemoteWindow(): boolean {
return useRemote.getState().session !== null;
}
/** Whether `path` is the project folder of this window's remote session. */
export function isRemoteRoot(path: string): boolean {
return useRemote.getState().session?.root === path;
}
/** The OS this window's commands run on: the remote server's, else this desktop's (`usePlatform`). */
export function hostOs(): string {
return useRemote.getState().session?.os ?? usePlatform.getState().os;
@@ -181,6 +189,7 @@ export async function connectRemote(target: RemoteTarget, path: string) {
if (takeLegacyRemoteTrust(r.target, path, r.host)) {
const status = await call(commands.projectTrustStatus(r.root));
if (!status.decided) await call(commands.projectTrustSet(r.root, true, false));
if (!(await call(commands.remoteTrustStatus())).decided) await call(commands.remoteTrustSet(true));
}
try {
await confirmProjectTrust(r.root);
+26 -5
Переглянути файл
@@ -109,6 +109,8 @@ const selectable = (r: Resolved | undefined) => !!r && r.kind === "item" && !r.d
interface MenuListProps {
items: MenuItem[];
/** Reserve space for item icons even when an item has none. */
iconColumn?: boolean;
/** Called after an item ran (closes the whole menu chain). */
onDone: () => void;
/** Close just this level (submenus). */
@@ -125,6 +127,7 @@ interface MenuListProps {
export function MenuList({
items,
iconColumn = true,
onDone,
onCloseLevel,
autoFocusFirst = true,
@@ -384,9 +387,11 @@ export function MenuList({
{numbersOf[i] ?? ""}
</span>
)}
<span className={styles.icon} data-menu-icon={!r.checked && r.icon ? "" : undefined}>
{r.checked ? <Icon name="check" size={13} /> : r.icon ? <Icon name={r.icon} size={14} /> : null}
</span>
{iconColumn && (
<span className={styles.icon} data-menu-icon={!r.checked && r.icon ? "" : undefined}>
{r.checked ? <Icon name="check" size={13} /> : r.icon ? <Icon name={r.icon} size={14} /> : null}
</span>
)}
<span className={styles.label} title={r.label}>
{r.label}
</span>
@@ -428,6 +433,7 @@ export interface MenuProps {
onClose: () => void;
anchor: PopupAnchor;
items: MenuItem[];
iconColumn?: boolean;
placement?: Placement;
"aria-label"?: string;
/** Numeric mnemonics on the first nine rows; see `MenuList`. */
@@ -435,7 +441,16 @@ export interface MenuProps {
}
/** A dropdown menu anchored to an element or point. */
export function Menu({ open, onClose, anchor, items, placement = "bottom-start", numbers, ...aria }: MenuProps) {
export function Menu({
open,
onClose,
anchor,
items,
placement = "bottom-start",
iconColumn,
numbers,
...aria
}: MenuProps) {
return (
<Popup
open={open}
@@ -446,7 +461,13 @@ export function Menu({ open, onClose, anchor, items, placement = "bottom-start",
autoFocus={false}
className={styles.popup}
>
<MenuList items={items} onDone={onClose} numbers={numbers} aria-label={aria["aria-label"]} />
<MenuList
items={items}
onDone={onClose}
iconColumn={iconColumn}
numbers={numbers}
aria-label={aria["aria-label"]}
/>
</Popup>
);
}