дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-13 05:05:40 +02:00
Порівняти коміти
4
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
8df59cf5a7 | ||
|
|
3c68d2ea7f | ||
|
|
cfe1416dbd | ||
|
|
44fc0a2ebc |
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() => {}
|
||||
_ => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.": "允许此确切主机的任意端口;子域名需要单独授权。",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача