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

..
Автор SHA1 Повідомлення Дата
l0ng-ai d369f8ac10 fix(java): build with Maven when JDT's annotation processing recreates a file
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:43:53 +08:00
27 змінених файлів з 91 додано та 861 видалено

No files matched your search

+3 -94
Переглянути файл
@@ -47,48 +47,6 @@ 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 {
@@ -176,13 +134,8 @@ 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). 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.
/// owns the plugin list, themes and consent).
pub fn forwarded(event: &PluginEvent) -> bool {
if let PluginEvent::ExecuteCommand { id, .. } = event {
return plugin_command(id);
}
matches!(
event,
PluginEvent::RunTargetsChanged
@@ -194,6 +147,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::WindowStatusItemChanged { .. }
| PluginEvent::ExecuteCommand { .. }
| PluginEvent::OutputChanged { .. }
| PluginEvent::RevealOutput { .. }
| PluginEvent::ContextChanged { .. }
@@ -209,6 +163,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenLogTab { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::PromptWaiting { .. }
@@ -218,13 +173,6 @@ 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 {
@@ -1003,43 +951,4 @@ 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,56 +298,6 @@ 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>,
@@ -414,56 +364,3 @@ 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:?}");
}
}
}
+2 -57
Переглянути файл
@@ -18,15 +18,6 @@ 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
@@ -49,37 +40,7 @@ impl TrustStore {
}
Err(e) => Err(e.into()),
};
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(())
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
@@ -172,7 +133,7 @@ impl TrustStore {
}
}
fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
@@ -411,8 +372,6 @@ 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"
@@ -567,18 +526,4 @@ 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,29 +455,3 @@ 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,9 +21,6 @@ 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
Переглянути файл
@@ -1,33 +0,0 @@
//! 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{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
-3
Переглянути файл
@@ -58,9 +58,6 @@ 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
Переглянути файл
@@ -1,61 +0,0 @@
//! 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
}
+22 -4
Переглянути файл
@@ -132,11 +132,29 @@ impl Syntax {
SPECS.iter().map(|s| s.name)
}
/// 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.
/// Changes whenever extraction output may change (grammar set, queries,
/// crate version); persistent caches of [`Syntax::tags`] key on it.
pub fn fingerprint(&self) -> String {
env!("SYNTAX_SOURCE_HASH").to_owned()
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()
}
/// Whether a tree-sitter grammar is bundled for this language.
-23
Переглянути файл
@@ -74,26 +74,3 @@ 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,8 +699,6 @@ 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,
+24 -6
Переглянути файл
@@ -162,12 +162,30 @@ pub fn run() {
let webview = request.message.webview_ref();
if let Some(state) = webview.try_state::<AppState>() {
let label = AppState::owner_window(webview.label());
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 {
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()) {
request.resolver.reject(error);
return true;
}
+19 -140
Переглянути файл
@@ -104,35 +104,25 @@ type Channels = Arc<Mutex<HashMap<u64, (Channel<ChannelFrame>, u64)>>>;
pub struct Connections(Mutex<HashMap<String, Arc<Connection>>>);
impl Connection {
/// 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) {
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) {
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 {
@@ -207,7 +197,7 @@ pub struct RemoteProject {
pub host: String,
pub server_version: String,
pub os: String,
/// Commands routed to the server (the rest run locally).
/// Commands the server answers (the rest run locally).
pub commands: Vec<String>,
pub project: ide_host::ops::project::ProjectInfo,
}
@@ -222,12 +212,11 @@ impl Handler for ClientHandler {
fn on_notification(&self, method: &str, params: Value) {
match method {
"event" => match serde_json::from_value::<EventMessage>(params) {
Ok(m) if ide_host::rpc::client_accepts(&m.event) => {
Ok(m) => {
// 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.
@@ -338,12 +327,6 @@ 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`].
@@ -536,7 +519,8 @@ pub async fn remote_connect(
ide_host::rpc::PROTOCOL_VERSION
)));
}
let commands: Vec<String> = ide_host::rpc::client_commands().into_iter().map(str::to_owned).collect();
let commands: Vec<String> =
peer.request("commands", Value::Null, None).await.map_err(ide_host::rpc::error_from_reply)?;
let root = if path.trim().is_empty() || path.trim() == "~" {
info.home.clone()
} else if let Some(rest) = path.trim().strip_prefix("~/") {
@@ -550,9 +534,6 @@ 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,
@@ -693,27 +674,6 @@ 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")]
@@ -965,7 +925,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(&state.host.trust, "fs_copy")?;
c.authorize_command(label, "fs_copy").await?;
let cancel = state.host.begin_request(request);
let result = async {
let fs = ide_ssh::RemoteFs::new(session.sftp().await?);
@@ -1011,87 +971,6 @@ 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()));
+2 -6
Переглянути файл
@@ -1634,10 +1634,6 @@ 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"),
@@ -8724,7 +8720,7 @@ export type RemoteProject_Deserialize = {
host: string,
serverVersion: string,
os: string,
/** Commands routed to the server (the rest run locally). */
/** Commands the server answers (the rest run locally). */
commands: string[],
project: ProjectInfo_Deserialize,
};
@@ -8736,7 +8732,7 @@ export type RemoteProject_Serialize = {
host: string,
serverVersion: string,
os: string,
/** Commands routed to the server (the rest run locally). */
/** Commands the server answers (the rest run locally). */
commands: string[],
project: ProjectInfo_Serialize,
};
-19
Переглянути файл
@@ -1,19 +0,0 @@
.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
Переглянути файл
@@ -1,58 +0,0 @@
/**
* 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
Переглянути файл
@@ -1,93 +0,0 @@
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();
});
});
+3 -17
Переглянути файл
@@ -33,10 +33,9 @@ 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 { isOpenable, openExternalUrl } from "@/platform/openUrl";
import { 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";
@@ -47,7 +46,6 @@ 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";
@@ -607,15 +605,13 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
return true;
case "copyToClipboard":
if (isRemoteWindow()) void copyRemoteText(e.pluginId, e.text);
else void writeClipboardText(e.text);
void writeClipboardText(e.text);
return true;
case "openProject":
void executeAction("file.openProjectPath", { path: e.path, newWindow: e.newWindow });
return true;
case "openUrl":
if (isRemoteWindow()) void openRemoteUrl(e.pluginId, e.url);
else void openExternalUrl(e.url);
void openExternalUrl(e.url);
return true;
case "promptRequested":
void answerPrompt(e.promptId, e.prompt, e.pluginId);
@@ -638,16 +634,6 @@ 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;
+2 -11
Переглянути файл
@@ -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, isRemoteRoot } from "@/platform/remote";
import { initializeRemoteTrustMigration } from "@/platform/remote";
import { provideProjectTrustPrompt, type ProjectTrustScope } from "@/platform/projectTrust";
import { invokeLocal } from "@/platform/tauriCore";
import { registerOverlay, registerSettingsPage, registerWorkbenchBanner } from "@/workbench/contributions";
@@ -200,7 +200,6 @@ 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 });
}
@@ -240,12 +239,6 @@ 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);
}),
@@ -274,9 +267,7 @@ export function activate(): () => void {
category: "Project",
run: async ({ arg }) => {
const path = typeof arg === "string" ? arg : useProject.getState().project?.root;
if (!path) return;
await call(commands.projectTrustSet(path, false, false));
if (isRemoteRoot(path)) await call(commands.remoteTrustSet(false));
if (path) await call(commands.projectTrustSet(path, false, false));
},
},
{
+1 -49
Переглянути файл
@@ -4,7 +4,6 @@ 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";
@@ -30,14 +29,10 @@ 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();
@@ -45,56 +40,13 @@ 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();
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);
});
afterEach(() => dispose());
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 barRef = useRef<HTMLDivElement>(null);
const historyRef = useRef<HTMLButtonElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const historyTick = useCommit((s) => s.historyTick);
const checkProblems = useCommit((s) => s.checkProblems);
@@ -1025,9 +1025,10 @@ export function CommitMessageField({
return (
<>
<div ref={barRef} className={styles.commitBar}>
<div className={styles.commitBar}>
<Checkbox checked={amend} onChange={(v) => void setAmend(v)} label="Amend" />
<IconButton
ref={historyRef}
icon="history"
title="Commit Message History"
size="small"
@@ -1039,9 +1040,8 @@ export function CommitMessageField({
<Menu
open={historyOpen}
onClose={() => setHistoryOpen(false)}
anchor={barRef}
placement="top-start"
iconColumn={false}
anchor={historyRef}
placement="bottom-end"
aria-label="Recent commit messages"
items={history.map((m, i) => ({
id: `h${i}`,
-5
Переглянути файл
@@ -1027,11 +1027,6 @@
"\"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,11 +7387,6 @@
"\"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. */
export function isOpenable(url: string): boolean {
function isOpenable(url: string): boolean {
if (url !== url.trim()) return false;
try {
return OPENABLE_PROTOCOLS.has(new URL(url).protocol);
+1 -10
Переглянути файл
@@ -118,10 +118,7 @@ const route: InvokeRoute = (cmd, args, options) => {
});
};
/**
* 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).
*/
/** Routes the commands the server answers (`session.commands`) to it from now on. */
export function enableRemote(session: RemoteSession) {
routed = new Set(session.commands);
useRemote.setState({ session });
@@ -138,11 +135,6 @@ 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;
@@ -189,7 +181,6 @@ 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);
+5 -26
Переглянути файл
@@ -109,8 +109,6 @@ 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). */
@@ -127,7 +125,6 @@ interface MenuListProps {
export function MenuList({
items,
iconColumn = true,
onDone,
onCloseLevel,
autoFocusFirst = true,
@@ -387,11 +384,9 @@ export function MenuList({
{numbersOf[i] ?? ""}
</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.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>
@@ -433,7 +428,6 @@ 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`. */
@@ -441,16 +435,7 @@ export interface MenuProps {
}
/** A dropdown menu anchored to an element or point. */
export function Menu({
open,
onClose,
anchor,
items,
placement = "bottom-start",
iconColumn,
numbers,
...aria
}: MenuProps) {
export function Menu({ open, onClose, anchor, items, placement = "bottom-start", numbers, ...aria }: MenuProps) {
return (
<Popup
open={open}
@@ -461,13 +446,7 @@ export function Menu({
autoFocus={false}
className={styles.popup}
>
<MenuList
items={items}
onDone={onClose}
iconColumn={iconColumn}
numbers={numbers}
aria-label={aria["aria-label"]}
/>
<MenuList items={items} onDone={onClose} numbers={numbers} aria-label={aria["aria-label"]} />
</Popup>
);
}