mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(project): add project trust and restricted execution mode (#517)
Projects now open with a user-owned trust decision. The prompt offers Preview in Restricted Mode, Don't Open and Trust Project, with an optional trusted parent folder; preview is the default focus so Enter never grants trust. Restricted projects keep browsing, editing, saving, search and highlighting. Project-controlled execution is denied by default in the backend: run, debug, build, tools, language servers, plugins and Git commands that could honour repository configuration. Trusting a project enables those paths without reopening it, and decisions can be revoked in Settings. Terminals stay available when the user opens one, without project shell settings, environment or virtualenv activation. A shell prompt that runs git in the folder can still honour the repository's core.fsmonitor. Fixes #462
This commit is contained in:
@@ -15,6 +15,10 @@ use std::sync::Arc;
|
||||
pub enum IdeEvent {
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
ProjectTrustChanged {
|
||||
root: crate::IpcPath,
|
||||
trusted: bool,
|
||||
},
|
||||
/// Files or directories changed on disk (after debouncing, ignore rules applied).
|
||||
FsChanged {
|
||||
created: Vec<crate::IpcPath>,
|
||||
|
||||
@@ -44,6 +44,7 @@ struct State {
|
||||
project: Changes,
|
||||
/// Project roots whose `postStartCommand` ran in this process.
|
||||
post_started: HashSet<PathBuf>,
|
||||
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
|
||||
}
|
||||
|
||||
pub struct Environment {
|
||||
@@ -78,7 +79,12 @@ impl Environment {
|
||||
|
||||
pub(crate) fn project_closed(&self) {
|
||||
if self.project_env {
|
||||
self.state.lock().project.clear();
|
||||
let mut state = self.state.lock();
|
||||
for (_, token) in state.post_commands.drain() {
|
||||
token.cancel();
|
||||
}
|
||||
state.project.clear();
|
||||
drop(state);
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
@@ -144,6 +150,7 @@ impl Environment {
|
||||
/// a failure is a notification.
|
||||
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
|
||||
let cancel = self.cancel.child_token();
|
||||
self.state.lock().post_commands.insert(root.clone(), cancel.clone());
|
||||
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
|
||||
run_post_start(&cmd, &root, &sink, &cancel);
|
||||
});
|
||||
@@ -263,6 +270,9 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel
|
||||
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
|
||||
/// to `on_line`; `Err` says how it failed.
|
||||
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let (program, args) = argv.split_first().ok_or("empty command")?;
|
||||
let mut child = ide_core::process::prepare(&mut Command::new(program))
|
||||
.args(args)
|
||||
|
||||
@@ -12,6 +12,7 @@ mod proxy;
|
||||
mod requests;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
pub mod trust;
|
||||
|
||||
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
|
||||
|
||||
@@ -44,6 +45,7 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub trust: trust::TrustStore,
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
@@ -67,6 +69,7 @@ pub struct Host {
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -77,6 +80,7 @@ impl Host {
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
@@ -95,6 +99,7 @@ impl Host {
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -119,6 +124,10 @@ impl Host {
|
||||
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
|
||||
}
|
||||
|
||||
pub fn require_trust(&self, window: &str) -> IdeResult<()> {
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
|
||||
/// The window that owns what `window` starts.
|
||||
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
|
||||
(self.owner)(window)
|
||||
@@ -212,7 +221,19 @@ impl Host {
|
||||
self.requests.begin(id)
|
||||
}
|
||||
|
||||
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
|
||||
token.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
for ids in self.project_requests.write().values_mut() {
|
||||
ids.retain(|entry| *entry != id);
|
||||
}
|
||||
self.requests.end(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -407,6 +407,24 @@ impl NotebookService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restrict(&self) {
|
||||
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
|
||||
for document in &documents {
|
||||
let mut state = document.state.lock();
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
}
|
||||
for document in documents {
|
||||
let _ = self.kernels.shutdown_kernel(&document.path).await;
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Err(error) = self.drain(&document, batch).await {
|
||||
tracing::warn!("notebook restriction: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.stop_admission();
|
||||
self.kernels.shutdown().await;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
|
||||
}
|
||||
|
||||
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
host.require_trust(window)?;
|
||||
let mut providers = host.project(window)?.agents.providers();
|
||||
ide_agent::registry::mark_updates(®istry_paths(host), &mut providers);
|
||||
Ok(providers)
|
||||
@@ -88,6 +89,7 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let requested = request.project_root.take();
|
||||
@@ -99,6 +101,7 @@ pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> Ide
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.prompt(&task, prompt, now).await
|
||||
}
|
||||
|
||||
@@ -109,26 +112,31 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe
|
||||
|
||||
/// Sends a queued message now, interrupting the current turn.
|
||||
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.send_queued_now(&task, &queued)
|
||||
}
|
||||
|
||||
/// Changes a session setting (model, mode, thinking effort…).
|
||||
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
/// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`).
|
||||
pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.fork(&task, turns).await
|
||||
}
|
||||
|
||||
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
|
||||
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback(&task, turns).await
|
||||
}
|
||||
|
||||
/// What the agent reports about itself when it starts, without opening a session.
|
||||
pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult<AgentProbe> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.probe(&provider, refresh).await
|
||||
}
|
||||
|
||||
@@ -139,6 +147,7 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
|
||||
|
||||
/// Answers a permission request; `option: None` cancels it.
|
||||
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.respond(&task, permission, option)
|
||||
}
|
||||
|
||||
@@ -151,15 +160,18 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
|
||||
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
|
||||
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
@@ -93,9 +94,10 @@ pub async fn build_sync(
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_request(request);
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
@@ -223,11 +225,12 @@ pub async fn build_run(
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let config_dir = host.config_dir.clone();
|
||||
let token = host.begin_request(request);
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
/// How to run `config` with coverage: the command to launch and where its report will be,
|
||||
/// or which tool is missing.
|
||||
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
@@ -26,7 +27,8 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
host.require_trust(window)?;
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
host.require_trust(window)?;
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -76,6 +77,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
let result = host.debugger.start_with(config, owner, custom).await;
|
||||
flush_output();
|
||||
result
|
||||
@@ -307,11 +309,12 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
request: ide_core::RequestId,
|
||||
jvm_host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
|
||||
host.end_request(request);
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
/// Re-reads the login shell (and the window's project `remoteEnv` where it applies);
|
||||
/// running terminals and processes keep their environment.
|
||||
pub async fn reload(host: &Arc<Host>, window: &str) -> IdeResult<EnvReloaded> {
|
||||
let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone());
|
||||
let root = host.project(window).ok().filter(|p| !p.light && p.is_trusted()).map(|p| p.root.clone());
|
||||
let sink = host.window_sink(window);
|
||||
let h = host.clone();
|
||||
host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await
|
||||
|
||||
@@ -20,7 +20,8 @@ pub async fn java_resolve_launch(
|
||||
build: bool,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = resolve_launch(host, window, module, test, build, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
@@ -104,6 +105,7 @@ async fn resolve_launch(
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_debug_server(&module).await
|
||||
@@ -115,6 +117,7 @@ pub async fn java_build_workspace(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
|
||||
@@ -54,6 +54,7 @@ pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<Notebook
|
||||
}
|
||||
|
||||
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
host.require_trust(window)?;
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.reload(&id, true).await })
|
||||
.await
|
||||
@@ -84,8 +85,9 @@ pub async fn execute(
|
||||
request: RequestId,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let _request = RequestGuard { host, request, cancel: cancel.clone() };
|
||||
// Reserve the WHOLE batch and capture source before asynchronous discovery.
|
||||
let admission = project.notebooks.admit(&id, cells, cancel.clone())?;
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::Arc;
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInfo {
|
||||
pub trusted: bool,
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
@@ -22,6 +23,7 @@ pub fn info(project: &Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
ProjectInfo {
|
||||
trusted: project.is_trusted(),
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
|
||||
/// Detected configs followed by user-defined ones (user ids win on conflict).
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
@@ -70,6 +71,7 @@ pub fn save_configs(
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
@@ -135,6 +137,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
};
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
|
||||
let previous = sink;
|
||||
|
||||
@@ -50,6 +50,7 @@ pub async fn run_test_reports(
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult<ide_run::tomcat::TomcatHome> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home)))
|
||||
.await
|
||||
@@ -57,6 +58,7 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
|
||||
@@ -67,6 +69,7 @@ pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<id
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult<Option<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -119,6 +122,7 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
|
||||
|
||||
@@ -22,12 +22,14 @@ pub struct LocatedTools {
|
||||
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
@@ -35,6 +37,7 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -56,11 +59,12 @@ pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<i
|
||||
|
||||
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
|
||||
pub async fn sdk_locate_tools(
|
||||
_host: &Host,
|
||||
_window: &str,
|
||||
host: &Host,
|
||||
window: &str,
|
||||
names: Vec<String>,
|
||||
near: Option<PathBuf>,
|
||||
) -> IdeResult<LocatedTools> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(move || LocatedTools {
|
||||
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
|
||||
compile_dir: ide_sdk::locate::compile_dir().into(),
|
||||
@@ -72,13 +76,14 @@ pub async fn sdk_locate_tools(
|
||||
/// `base -m venv location`; output lines stream through `on_output`.
|
||||
pub async fn sdk_python_create_venv(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
base: PathBuf,
|
||||
location: PathBuf,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
@@ -92,11 +97,12 @@ pub async fn sdk_python_create_venv(
|
||||
|
||||
pub async fn sdk_python_packages(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
python: PathBuf,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PythonPackage>> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
@@ -107,7 +113,7 @@ pub async fn sdk_python_packages(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sdk_python_pip(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
python: PathBuf,
|
||||
requirement: String,
|
||||
uninstall: bool,
|
||||
@@ -115,7 +121,8 @@ pub async fn sdk_python_pip(
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let send = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
|
||||
@@ -3,12 +3,24 @@ use crate::Host;
|
||||
use ide_core::{IdeResult, TerminalId};
|
||||
use ide_terminal::{TerminalInfo, TerminalOptions};
|
||||
|
||||
pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit<String>) -> IdeResult<TerminalInfo> {
|
||||
pub fn spawn(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut options: TerminalOptions,
|
||||
on_output: Emit<String>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
if !host.project(window).is_ok_and(|p| p.is_trusted()) {
|
||||
// Project terminal settings can otherwise start a command as soon as a tab restores.
|
||||
let global = host.global_settings.effective().terminal;
|
||||
options.shell = None;
|
||||
options.args.clear();
|
||||
options.env = global.env;
|
||||
}
|
||||
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
|
||||
let activation = host
|
||||
.project(window)
|
||||
.ok()
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()));
|
||||
let activation =
|
||||
host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| {
|
||||
ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())
|
||||
});
|
||||
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
|
||||
if let Some(line) = activation {
|
||||
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
host.require_trust(window)?;
|
||||
let data = host.data_dir.clone();
|
||||
let (settings, root) = match host.project(window) {
|
||||
Ok(project) => (project.settings.effective(), project.root.clone()),
|
||||
|
||||
@@ -7,7 +7,9 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
if project.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
let repos = project.repos();
|
||||
if repos.is_empty() {
|
||||
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
|
||||
|
||||
@@ -259,6 +259,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
}
|
||||
|
||||
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
@@ -271,6 +272,7 @@ pub async fn vcs_add_to_ignore(
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
for path in &paths {
|
||||
@@ -292,16 +294,19 @@ pub async fn vcs_add_to_ignore(
|
||||
}
|
||||
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch, &|path| project.vcs_encoding(path, Some(":0"))))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
@@ -313,6 +318,7 @@ pub async fn vcs_commit(
|
||||
message: String,
|
||||
options: CommitOptions,
|
||||
) -> IdeResult<String> {
|
||||
host.require_trust(window)?;
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
@@ -331,6 +337,7 @@ pub async fn vcs_checkout(
|
||||
create: bool,
|
||||
options: Option<ide_vcs::CheckoutOptions>,
|
||||
) -> IdeResult<ide_vcs::CheckoutOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let options = options.unwrap_or_default();
|
||||
blocking(move || {
|
||||
@@ -349,6 +356,7 @@ pub async fn vcs_create_branch(
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let recorded = checkout.then_some("Git checkout");
|
||||
blocking(move || {
|
||||
@@ -360,6 +368,7 @@ pub async fn vcs_create_branch(
|
||||
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
|
||||
/// Returns the undone commit's message.
|
||||
pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, commit: String) -> IdeResult<String> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
|
||||
}
|
||||
@@ -370,6 +379,7 @@ pub async fn vcs_delete_branch(
|
||||
repo: Option<PathBuf>,
|
||||
branch: String,
|
||||
) -> IdeResult<ide_vcs::DeletedBranch> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await
|
||||
}
|
||||
@@ -381,6 +391,7 @@ pub async fn vcs_restore_branch(
|
||||
repo: Option<PathBuf>,
|
||||
deleted: ide_vcs::DeletedBranch,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await
|
||||
}
|
||||
@@ -392,16 +403,19 @@ pub async fn vcs_rename_branch(
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_rebase(host: &Host, window: &str, repo: Option<PathBuf>, onto: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
|
||||
}
|
||||
@@ -414,6 +428,7 @@ pub async fn vcs_stash_push(
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
@@ -432,6 +447,7 @@ pub async fn vcs_stash_apply(
|
||||
reinstate_index: bool,
|
||||
branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() {
|
||||
@@ -443,6 +459,7 @@ pub async fn vcs_stash_apply(
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option<PathBuf>, index: u32) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
|
||||
}
|
||||
@@ -456,6 +473,7 @@ pub async fn vcs_remote(
|
||||
op: RemoteOp,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -514,6 +532,7 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
|
||||
/// Stages only the selected hunks of a file's working-tree changes.
|
||||
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
|
||||
pub async fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
@@ -556,7 +575,8 @@ pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()>
|
||||
|
||||
/// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected
|
||||
/// executable when `path` is empty.
|
||||
pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
pub async fn vcs_git_version(host: &Host, window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let explicit = path.trim();
|
||||
let exe = if explicit.is_empty() {
|
||||
@@ -598,6 +618,7 @@ pub async fn vcs_operation(
|
||||
op: GitOperation,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -638,6 +659,7 @@ pub async fn vcs_create_tag(
|
||||
message: Option<String>,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
|
||||
.await
|
||||
@@ -656,6 +678,7 @@ pub async fn vcs_merge_with(
|
||||
branches: Vec<String>,
|
||||
options: MergeOptions,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
|
||||
@@ -671,6 +694,7 @@ pub async fn vcs_rebase_with(
|
||||
upstream: String,
|
||||
options: RebaseOptions,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
|
||||
@@ -684,6 +708,7 @@ pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
|
||||
}
|
||||
@@ -701,6 +726,7 @@ pub async fn vcs_add_worktree(
|
||||
commitish: String,
|
||||
new_branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
@@ -715,6 +741,7 @@ pub async fn vcs_remove_worktree(
|
||||
path: PathBuf,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
|
||||
}
|
||||
@@ -790,12 +817,14 @@ pub async fn vcs_apply_patch(
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<PathBuf>) -> IdeResult<ShelvedChangeList> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
in_primary_repo(&project, &paths)?;
|
||||
@@ -819,6 +848,7 @@ pub async fn vcs_unshelve(
|
||||
remove: bool,
|
||||
path: Option<String>,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| {
|
||||
|
||||
@@ -15,9 +15,11 @@ use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoo
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
pub(crate) trusted: Arc<AtomicBool>,
|
||||
pub root: PathBuf,
|
||||
/// Window-scoped event sink, for commands that emit project events.
|
||||
pub sink: SharedSink,
|
||||
@@ -80,7 +82,9 @@ impl Host {
|
||||
self.close_project(window);
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let post_start = self.environment.project_opened(&root, &sink);
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
let post_start =
|
||||
trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten();
|
||||
let settings = SettingsStore::load(&self.config_dir, Some(&root));
|
||||
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
|
||||
let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read());
|
||||
@@ -114,6 +118,7 @@ impl Host {
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
@@ -208,9 +213,13 @@ impl Host {
|
||||
root: root.clone(),
|
||||
sink: sink.clone(),
|
||||
runtime: self.runtime.clone(),
|
||||
trusted: trusted.clone(),
|
||||
}));
|
||||
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
let watcher = {
|
||||
let repos = repos.clone();
|
||||
let settings = settings.clone();
|
||||
@@ -224,6 +233,7 @@ impl Host {
|
||||
let remote = self.remote.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
let lsp = lsp.clone();
|
||||
let trusted = trusted.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
@@ -241,7 +251,7 @@ impl Host {
|
||||
documents.redetect_languages();
|
||||
}
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
if !batch.removed.is_empty() {
|
||||
if trusted.load(Ordering::Acquire) && !batch.removed.is_empty() {
|
||||
delete_deployed(
|
||||
&runtime,
|
||||
remote.clone(),
|
||||
@@ -331,6 +341,7 @@ impl Host {
|
||||
);
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
let project = Arc::new(Project {
|
||||
trusted,
|
||||
root,
|
||||
sink,
|
||||
languages,
|
||||
@@ -446,6 +457,7 @@ impl Host {
|
||||
sink.clone(),
|
||||
);
|
||||
let project = Arc::new(Project {
|
||||
trusted: Arc::new(AtomicBool::new(true)),
|
||||
sink,
|
||||
languages,
|
||||
settings,
|
||||
@@ -476,6 +488,18 @@ impl Host {
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn is_trusted(&self) -> bool {
|
||||
self.trusted.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn require_trust(&self) -> IdeResult<()> {
|
||||
if self.is_trusted() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable("Project is in Restricted Mode. Trust the project to enable execution."))
|
||||
}
|
||||
}
|
||||
|
||||
/// LightEdit: follow external changes to the files of `dir` (its own entries only).
|
||||
pub fn watch_light_dir(&self, dir: &Path) {
|
||||
if !self.light {
|
||||
@@ -550,7 +574,11 @@ impl Project {
|
||||
|
||||
/// The project's git repositories.
|
||||
pub fn repos(&self) -> Repos {
|
||||
self.repos.read().clone()
|
||||
let repos = self.repos.read().clone();
|
||||
for repo in repos.all() {
|
||||
repo.set_execution_allowed(self.is_trusted());
|
||||
}
|
||||
repos
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository whose root is `at`, the project's primary repository
|
||||
@@ -582,7 +610,9 @@ impl Project {
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
if self.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
repo.set_ignored_names(settings.ignored_names());
|
||||
f(repo)
|
||||
}
|
||||
@@ -981,6 +1011,7 @@ impl DocumentObserver for HistoryObserver {
|
||||
|
||||
/// Uploads saved files: remote files' local copies, and automatic upload to the default server.
|
||||
struct DeployObserver {
|
||||
trusted: Arc<AtomicBool>,
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
@@ -990,6 +1021,9 @@ struct DeployObserver {
|
||||
|
||||
impl DocumentObserver for DeployObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let (remote, settings, root, sink, path) =
|
||||
(self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf());
|
||||
self.runtime.spawn(async move {
|
||||
|
||||
@@ -24,7 +24,7 @@ use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -138,6 +138,15 @@ impl Dispatcher {
|
||||
/// terminals, runs and VCS status.
|
||||
pub fn standard() -> Self {
|
||||
let mut d = Self::default();
|
||||
d.add("project_trust_status", |c| async move { c.host.trust.status(&c.arg::<PathBuf>("path")?) })
|
||||
.add("project_trust_list", |c| async move { c.host.trust.decisions() })
|
||||
.add("project_trust_set", |c| async move {
|
||||
c.host.set_project_trust(&c.arg::<PathBuf>("path")?, c.arg("trusted")?, c.arg("folder")?).await
|
||||
})
|
||||
.add("project_trust_remove_location", |c| async move {
|
||||
c.host.trust.remove_location(&c.arg::<PathBuf>("path")?)?;
|
||||
c.host.refresh_project_trust().await
|
||||
});
|
||||
d.add("open_project", |c| async move {
|
||||
let path: PathBuf = c.arg("path")?;
|
||||
let project = ops::project::open(&c.host, &c.window, &path).await?;
|
||||
@@ -291,6 +300,7 @@ impl Handler for ServerHandler {
|
||||
let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else {
|
||||
return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd)));
|
||||
};
|
||||
self.host.authorize_command(&call.window, &call.cmd).map_err(error_reply)?;
|
||||
let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() };
|
||||
// Requests run on blocking-capable threads: wait for the command here.
|
||||
self.host.runtime.block_on(handler(c)).map_err(error_reply)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! User-owned project execution decisions. Project settings never grant trust.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustDecisions {
|
||||
pub projects: Vec<PathBuf>,
|
||||
pub locations: Vec<PathBuf>,
|
||||
pub restricted: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct TrustStore {
|
||||
file: PathBuf,
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
}
|
||||
|
||||
impl TrustStore {
|
||||
pub fn load(config: &Path, data: &Path) -> Self {
|
||||
let file = data.join("project-trust.json");
|
||||
let state = match std::fs::read(&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 => {
|
||||
let projects = ide_workspace::RecentProjects::load(config)
|
||||
.paths()
|
||||
.into_iter()
|
||||
.filter_map(|p| dunce::canonicalize(p).ok())
|
||||
.collect();
|
||||
let migrated = TrustDecisions { projects, ..Default::default() };
|
||||
persist(&file, &migrated).map(|()| migrated)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state) }
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
|
||||
pub fn trusted(&self, path: &Path) -> IdeResult<bool> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let decisions = self.decisions()?;
|
||||
Ok(!decisions.restricted.contains(&root)
|
||||
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
|
||||
}
|
||||
|
||||
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
let root = match dunce::canonicalize(path) {
|
||||
Ok(root) => root,
|
||||
Err(_) if !trusted && path.is_absolute() => path.to_path_buf(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.projects.retain(|p| p != &root);
|
||||
next.restricted.retain(|p| p != &root);
|
||||
if trusted {
|
||||
next.projects.push(root.clone());
|
||||
if folder && let Some(parent) = root.parent() {
|
||||
let parent = parent.to_path_buf();
|
||||
if !next.locations.contains(&parent) {
|
||||
next.locations.push(parent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.restricted.push(root);
|
||||
}
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_location(&self, path: &Path) -> IdeResult<()> {
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.locations.retain(|p| p != path);
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(state).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(file, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
self.trust.decide(path, trusted, folder)?;
|
||||
self.refresh_project_trust().await
|
||||
}
|
||||
|
||||
pub async fn refresh_project_trust(&self) -> IdeResult<()> {
|
||||
for project in self.projects().into_iter().filter(|p| !p.light) {
|
||||
let trusted = self.trust.trusted(&project.root).unwrap_or(false);
|
||||
let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
|
||||
if previous == trusted {
|
||||
continue;
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
project.repos();
|
||||
if trusted {
|
||||
if let Some(cmd) = self.environment.project_opened(&project.root, &project.sink) {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
project.lsp.set_execution_allowed(true);
|
||||
} else {
|
||||
self.environment.project_closed();
|
||||
project.agents.shutdown();
|
||||
self.processes.kill_project(&project.root);
|
||||
project.notebooks.restrict().await;
|
||||
let windows: Vec<_> = self
|
||||
.projects
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, current)| std::sync::Arc::ptr_eq(current, &project))
|
||||
.map(|(window, _)| window.clone())
|
||||
.collect();
|
||||
for window in windows {
|
||||
let requests = self.project_requests.write().remove(&window).unwrap_or_default();
|
||||
for request in requests {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
for session in self.debugger.sessions(Some(&window)) {
|
||||
let _ = self.debugger.stop(session.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
project.lsp.reconfigure().await;
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands which only inspect/edit source or operate user-owned application state.
|
||||
/// New execution commands are denied until deliberately classified here.
|
||||
pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
if [
|
||||
"fs_",
|
||||
"doc_",
|
||||
"syntax_",
|
||||
"search_",
|
||||
"nav_",
|
||||
"history_",
|
||||
"spell_",
|
||||
"analysis_",
|
||||
"structural_",
|
||||
"refactor_",
|
||||
"bytes_",
|
||||
"window_",
|
||||
"window_group_",
|
||||
"term_",
|
||||
"settings_",
|
||||
"project_trust_",
|
||||
"ssh_",
|
||||
"git_credential",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| cmd.starts_with(p))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
cmd,
|
||||
"app_info"
|
||||
| "app_metrics"
|
||||
| "app_quit"
|
||||
| "app_cancel_quit"
|
||||
| "app_restart"
|
||||
| "languages"
|
||||
| "menu_refresh"
|
||||
| "menu_model"
|
||||
| "menu_configure"
|
||||
| "menu_set_disabled"
|
||||
| "lsp_servers"
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
| "remove_recent_project"
|
||||
| "open_project"
|
||||
| "close_project"
|
||||
| "project_current"
|
||||
| "project_roots"
|
||||
| "index_status"
|
||||
| "jvm_file_presentations"
|
||||
| "deploy_coverage"
|
||||
| "find_files"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
| "remove_recent"
|
||||
| "recent_files"
|
||||
| "web_goto"
|
||||
| "path_completions"
|
||||
| "web_unresolved_references"
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "workspace_create"
|
||||
| "workspace_update"
|
||||
| "workspace_attach"
|
||||
| "cancel_request"
|
||||
| "cancel_task"
|
||||
| "log_frontend"
|
||||
| "env_reload"
|
||||
| "remote_invoke"
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
| "web_browsers"
|
||||
| "web_url"
|
||||
| "web_open"
|
||||
| "debug_attachable_processes"
|
||||
| "agent_subscribe"
|
||||
| "agent_entries"
|
||||
| "agent_commands"
|
||||
| "agent_unqueue"
|
||||
| "agent_cancel"
|
||||
| "agent_archive"
|
||||
| "agent_unarchive"
|
||||
| "agent_registry_list"
|
||||
| "agent_registry_install"
|
||||
| "agent_registry_remove"
|
||||
| "coverage_import"
|
||||
| "system_font_families"
|
||||
| "plugin_subscribe"
|
||||
| "plugin_list"
|
||||
| "plugin_set_active_file"
|
||||
| "plugin_set_theme"
|
||||
| "plugin_set_theme_frame"
|
||||
| "plugin_catalog"
|
||||
| "notebook_open"
|
||||
| "notebook_get"
|
||||
| "notebook_edit"
|
||||
| "notebook_save"
|
||||
| "notebook_undo"
|
||||
| "notebook_redo"
|
||||
| "notebook_reload"
|
||||
| "notebook_close"
|
||||
| "notebook_interrupt"
|
||||
| "run_stop"
|
||||
| "run_list"
|
||||
| "debug_stop"
|
||||
| "debug_sessions"
|
||||
| "vcs_clone"
|
||||
| "vcs_init"
|
||||
| "vcs_status"
|
||||
| "vcs_roots"
|
||||
| "vcs_branch_info"
|
||||
| "vcs_line_hunks"
|
||||
| "vcs_file_at"
|
||||
| "vcs_blame"
|
||||
| "vcs_log"
|
||||
| "vcs_commit_files"
|
||||
| "vcs_commits_changes"
|
||||
| "vcs_branches"
|
||||
| "vcs_stashes"
|
||||
| "vcs_conflict_versions"
|
||||
| "vcs_diff_text"
|
||||
| "vcs_diff_hunks"
|
||||
| "vcs_detected_git"
|
||||
| "vcs_config_get"
|
||||
| "vcs_tags"
|
||||
| "vcs_remotes"
|
||||
| "vcs_worktrees"
|
||||
| "vcs_diff_with_worktree"
|
||||
| "vcs_diff_revisions"
|
||||
| "vcs_merge_base"
|
||||
| "vcs_shelves"
|
||||
| "vcs_shelf_patch"
|
||||
| "vcs_changelists_load"
|
||||
) || cmd.starts_with("plugin:")
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
|
||||
if restricted_command_allowed(command) {
|
||||
return Ok(());
|
||||
}
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustStatus {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub trusted: bool,
|
||||
pub decided: bool,
|
||||
}
|
||||
impl TrustStore {
|
||||
pub fn status(&self, path: &Path) -> IdeResult<TrustStatus> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let trusted = self.trusted(&root)?;
|
||||
let state = self.decisions()?;
|
||||
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
|
||||
let original = "hello=\\u4f60\\u597d\\uD83D\\uDE00target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
|
||||
std::fs::write(&path, original).unwrap();
|
||||
let host = host(&base);
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
|
||||
@@ -100,6 +100,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
"postStartCommand": ["/bin/sh", "-c", "echo \"$FROM_PROJECT\" >> started.txt; echo post-start-output"],
|
||||
}"#,
|
||||
);
|
||||
host.trust.decide(&project, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
|
||||
assert_eq!(child_var("FROM_PROJECT"), "two-demo");
|
||||
assert!(child_var("PATH").starts_with("/project/bin:"), "{}", child_var("PATH"));
|
||||
@@ -158,6 +159,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// Reopening the project doesn't run postStartCommand again (once per server start).
|
||||
host.close_project("main");
|
||||
assert_eq!(child_var("FROM_PROJECT"), "", "remoteEnv leaves with the project");
|
||||
host.trust.decide(&project, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
|
||||
@@ -165,6 +167,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// A failing postStartCommand is a notification; the project opens regardless.
|
||||
let failing = tmp.join("failing");
|
||||
write(&failing.join(".devcontainer.json"), r#"{ "postStartCommand": "echo oops >&2; exit 7" }"#);
|
||||
host.trust.decide(&failing, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &failing)).unwrap();
|
||||
wait_for("the failure notification", || {
|
||||
events.0.lock().iter().any(|e| {
|
||||
@@ -176,6 +179,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// A broken file is a warning, not an error.
|
||||
let broken = tmp.join("broken");
|
||||
write(&broken.join(".devcontainer.json"), "{ \"remoteEnv\": ");
|
||||
host.trust.decide(&broken, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &broken)).unwrap();
|
||||
assert!(events.0.lock().iter().any(|e| matches!(e,
|
||||
IdeEvent::Notify { level: NotifyLevel::Warning, title, .. } if title == "devcontainer.json was not applied")));
|
||||
|
||||
@@ -70,11 +70,24 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
|
||||
let info: ServerInfo = peer.request("initialize", json!({ "version": 1 }), None).await.unwrap();
|
||||
let info: ServerInfo = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
assert_eq!(info.protocol, rpc::PROTOCOL_VERSION);
|
||||
|
||||
let opened = invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
assert_eq!(opened["name"], "demo");
|
||||
assert_eq!(opened["trusted"], false);
|
||||
invoke(&peer, "index_status", json!({})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "lsp_servers", json!({})).await.unwrap(), json!([]));
|
||||
invoke(&peer, "lsp_resend_diagnostics", json!({})).await.unwrap();
|
||||
let files = invoke(&peer, "find_files", json!({"query": "main", "limit": 10})).await.unwrap();
|
||||
assert!(files.is_array());
|
||||
for command in ["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start"] {
|
||||
let error = invoke(&peer, command, json!({})).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
|
||||
}
|
||||
let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) };
|
||||
assert!(peer.request::<_, Value>("invoke", call, None).await.is_err());
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
assert_eq!(current["name"], "demo");
|
||||
|
||||
@@ -247,6 +260,7 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"});
|
||||
invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()}))
|
||||
|
||||
@@ -21,6 +21,7 @@ async fn removed_detections_stay_removed_and_overrides_restore() {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"dev":"node app.js","test":"node test.js"}}"#).unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let detected = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(detected.len() >= 2);
|
||||
@@ -58,6 +59,7 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
@@ -88,6 +90,8 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
|
||||
let workspace = root.join("workspace");
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Native", vec![member.clone()], true).unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&workspace, true, false).unwrap();
|
||||
host.trust.decide(&member, true, false).unwrap();
|
||||
host.open_project("main", &workspace).unwrap();
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
let build = configs.iter().find(|c| c.kind == "cmake" && c.args.first().is_some_and(|a| a == "--build")).unwrap();
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops, trust::TrustStore};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn host(base: &Path, environment: bool) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: environment,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
let data = tmp.path().join("data");
|
||||
let a = tmp.path().join("projects/a");
|
||||
let b = tmp.path().join("projects/b");
|
||||
std::fs::create_dir_all(a.join(".openidea")).unwrap();
|
||||
std::fs::create_dir_all(b.join(".openidea")).unwrap();
|
||||
std::fs::write(a.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
|
||||
std::fs::write(b.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
|
||||
let recent = ide_workspace::RecentProjects::load(&config);
|
||||
recent.touch(&a, None).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(trust.trusted(&a).unwrap());
|
||||
assert!(!trust.status(&b).unwrap().decided);
|
||||
recent.touch(&b, None).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(!trust.trusted(&b).unwrap(), "migration is not repeated");
|
||||
trust.decide(&a, true, true).unwrap();
|
||||
assert!(trust.trusted(&b).unwrap());
|
||||
trust.decide(&b, false, false).unwrap();
|
||||
assert!(!trust.trusted(&b).unwrap(), "revocation overrides a trusted location");
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(!trust.trusted(&b).unwrap());
|
||||
trust.remove_location(&dunce::canonicalize(a.parent().unwrap()).unwrap()).unwrap();
|
||||
assert!(trust.trusted(&a).unwrap());
|
||||
trust.decide(&a, false, false).unwrap();
|
||||
assert!(!TrustStore::load(&config, &data).trusted(&a).unwrap());
|
||||
std::fs::write(data.join("project-trust.json"), "broken").unwrap();
|
||||
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn script(root: &Path, name: &str, marker: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = root.join(name);
|
||||
std::fs::write(&path, format!("#!/bin/sh\nprintf executed > '{marker}'\n")).unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("malicious");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
script(&root, "hook", "hook.marker");
|
||||
script(&root, "server", "lsp.marker");
|
||||
script(&root, "gradlew", "build.marker");
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join("main.py"), "print('safe')\n").unwrap();
|
||||
std::fs::write(root.join(".devcontainer.json"), r#"{"postStartCommand":["./hook"]}"#).unwrap();
|
||||
std::fs::write(root.join(".openidea/settings.toml"), format!(
|
||||
"[languageServers.trust-witness]\nlanguages = ['python']\ncommand = {:?}\n[languageServers.pyright]\nenabled = false\n", root.join("server").to_str().unwrap()
|
||||
)).unwrap();
|
||||
let host = host(&tmp.path().join("app"), true);
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
let doc = ops::doc::open(&host, "main", root.join("main.py")).await.unwrap();
|
||||
ops::doc::save(&host, "main", doc.id).await.unwrap();
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(project.index.status().files > 0);
|
||||
let sync = || {
|
||||
ops::build::build_sync(
|
||||
&host,
|
||||
"main",
|
||||
ide_build::BuildTool::Gradle,
|
||||
ide_core::RequestId(472),
|
||||
false,
|
||||
vec![],
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
};
|
||||
assert!(sync().await.is_err(), "restricted build marker: {}", root.join("build.marker").exists());
|
||||
assert!(
|
||||
ops::sdk::sdk_locate_tools(&host, "missing-window", vec!["server".into()], Some(root.clone())).await.is_err()
|
||||
);
|
||||
for command in [
|
||||
"run_start",
|
||||
"debug_start",
|
||||
"notebook_execute",
|
||||
"coverage_collect",
|
||||
"profiler_collect",
|
||||
"docker_compose_op",
|
||||
"agent_prompt",
|
||||
"sdk_detect",
|
||||
"build_sync",
|
||||
] {
|
||||
assert!(host.authorize_command("main", command).is_err(), "{command}");
|
||||
assert!(host.authorize_command("missing-window", command).is_err(), "{command}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
for marker in ["hook.marker", "lsp.marker", "build.marker"] {
|
||||
assert!(!root.join(marker).exists(), "{marker}");
|
||||
}
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
assert!(Arc::ptr_eq(&project, &host.project("main").unwrap()));
|
||||
assert_eq!(project.documents.snapshot(doc.id).unwrap().text, doc.text);
|
||||
sync().await.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !root.join("hook.marker").exists() || !root.join("lsp.marker").exists() {
|
||||
assert!(Instant::now() < deadline, "trusted services must start");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(root.join("build.marker").exists());
|
||||
host.set_project_trust(&root, false, false).await.unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
assert!(sync().await.is_err());
|
||||
ops::doc::save(&host, "main", doc.id).await.unwrap();
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_terminal_keeps_user_input_but_ignores_project_startup_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
host.global_settings
|
||||
.set(
|
||||
ide_workspace::SettingsScope::Global,
|
||||
"terminal.env",
|
||||
serde_json::json!({"ZDOTDIR": root, "ENV": "/dev/null", "BASH_ENV": "/dev/null"}),
|
||||
)
|
||||
.unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let terminal = ops::term::spawn(
|
||||
&host,
|
||||
"main",
|
||||
ide_terminal::TerminalOptions {
|
||||
cwd: root.clone().into(),
|
||||
shell: Some("/bin/sh".into()),
|
||||
args: vec!["-c".into(), "touch automatic.marker".into()],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shell_integration: false,
|
||||
},
|
||||
Arc::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
host.terminals.write(terminal.id, "printf user > terminal.marker; exit\n").unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !root.join("terminal.marker").exists() {
|
||||
assert!(Instant::now() < deadline, "user terminal input must run");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(!root.join("automatic.marker").exists());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn trusted_locations_use_canonical_directory_boundaries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("trusted/project");
|
||||
let other = tmp.path().join("trusted-other/project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::os::unix::fs::symlink(other.parent().unwrap(), root.parent().unwrap().join("escape")).unwrap();
|
||||
let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
trust.decide(&root, true, true).unwrap();
|
||||
assert!(!trust.trusted(&other).unwrap());
|
||||
let alias = root.parent().unwrap().join("escape/project");
|
||||
assert!(!trust.trusted(&alias).unwrap());
|
||||
assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git").current_dir(&root).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
};
|
||||
git(&["init", "-q"]);
|
||||
git(&["config", "user.name", "Test"]);
|
||||
git(&["config", "user.email", "test@example.com"]);
|
||||
git(&["config", "commit.gpgsign", "false"]);
|
||||
git(&["config", "core.hooksPath", ".git/hooks"]);
|
||||
let file = root.join("safe.txt");
|
||||
std::fs::write(&file, "safe\n").unwrap();
|
||||
script(&root, ".git/hooks/pre-commit", "git-hook.marker");
|
||||
let executable = root.join(".openidea/git-witness");
|
||||
std::fs::write(&executable, "#!/bin/sh\nprintf executed > git-executable.marker\nexec git \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".openidea/settings.toml"),
|
||||
format!("[vcs.git]\nexecutable = {:?}\n", executable.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(!project.with_repo(|repo| repo.status()).unwrap().is_empty());
|
||||
assert!(ops::vcs::vcs_stage(&host, "main", vec![file.clone()]).await.is_err());
|
||||
assert!(!root.join("git-hook.marker").exists());
|
||||
assert!(!root.join("git-executable.marker").exists());
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
ops::vcs::vcs_stage(&host, "main", vec![file]).await.unwrap();
|
||||
project.with_repo(|repo| repo.commit("trusted", false, false)).unwrap();
|
||||
assert!(root.join("git-executable.marker").exists());
|
||||
assert!(root.join("git-hook.marker").exists());
|
||||
ide_vcs::set_git_executable(None);
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn revocation_still_applies_when_a_project_folder_was_removed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
host.trust.decide(&root, true, true).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
host.set_project_trust(&project.root, false, false).await.unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! The configured Git binary is process-wide, so it has its own test binary.
|
||||
#![cfg(unix)]
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let root = base.join("malicious");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let marker = base.join("git.marker");
|
||||
let git = root.join("git");
|
||||
std::fs::write(&git, format!("#!/bin/sh\nprintf executed > '{}'\n", marker.display())).unwrap();
|
||||
std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::write(root.join(".openidea/settings.toml"), format!("[vcs.git]\nexecutable = {:?}\n", git)).unwrap();
|
||||
ide_vcs::init(&root).unwrap();
|
||||
|
||||
host.open_project("main", &root).unwrap();
|
||||
ops::vcs::status(&host, "main").await.unwrap();
|
||||
|
||||
let _ = ide_vcs::git_version(None);
|
||||
assert!(!marker.exists(), "another window's next Git command would run the project's binary");
|
||||
}
|
||||
@@ -35,6 +35,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
|
||||
@@ -38,6 +38,7 @@ async fn patches_read_and_write_the_requested_repository() {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&project_root, true, false).unwrap();
|
||||
let project = host.open_project("main", &project_root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if project.repos().all().len() == 2 {
|
||||
|
||||
@@ -30,6 +30,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
project_environment: false,
|
||||
});
|
||||
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert_eq!(ops::project::info(&project).name, "Full stack");
|
||||
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path());
|
||||
|
||||
@@ -174,6 +174,7 @@ pub struct LspManager {
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
|
||||
shutting_down: AtomicBool,
|
||||
execution_allowed: AtomicBool,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
shown_messages: Mutex<HashSet<(String, String)>>,
|
||||
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
|
||||
@@ -305,6 +306,7 @@ impl LspManager {
|
||||
candidates_hook: RwLock::new(None),
|
||||
excludes_hook: RwLock::new(None),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
execution_allowed: AtomicBool::new(true),
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
@@ -372,6 +374,9 @@ impl LspManager {
|
||||
|
||||
/// The definitions of the servers `language` uses.
|
||||
fn language_servers(&self, language: &LanguageId) -> Vec<LanguageServerConfig> {
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(cfg) = self.languages.get(language) else { return Vec::new() };
|
||||
match self.servers_hook.read().clone() {
|
||||
Some(hook) => hook(language, &cfg.language_servers),
|
||||
@@ -418,7 +423,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn ensure_started(&self, server: &Arc<Server>) {
|
||||
if self.shutting_down.load(Ordering::Acquire) {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let Some(generation) = server.begin_start() else { return };
|
||||
@@ -436,7 +441,13 @@ impl LspManager {
|
||||
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
|
||||
// servers may take a while to start listening.
|
||||
let launched = tokio::task::spawn_blocking(move || {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
let mut cfg = me.configured(&tail);
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
tail.set_effective_config(cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if !cancel.is_cancelled()
|
||||
@@ -453,6 +464,9 @@ impl LspManager {
|
||||
let missing = !me.launcher.available(&cfg, &root);
|
||||
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
|
||||
}
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
|
||||
})
|
||||
.await
|
||||
@@ -2008,6 +2022,10 @@ impl LspManager {
|
||||
/// The server's base configuration with the settings hook applied.
|
||||
fn configured(&self, server: &Server) -> LanguageServerConfig {
|
||||
let mut cfg = server.config.clone();
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
cfg.launch_error = Some("Project is in Restricted Mode".into());
|
||||
return cfg;
|
||||
}
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &server.root);
|
||||
}
|
||||
@@ -2018,7 +2036,27 @@ impl LspManager {
|
||||
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
|
||||
/// A change of `settings` alone reaches a running server without a restart.
|
||||
/// Servers no longer enabled stop; newly enabled ones attach to open documents.
|
||||
pub fn set_execution_allowed(&self, allowed: bool) {
|
||||
let previous = self.execution_allowed.swap(allowed, Ordering::AcqRel);
|
||||
if !allowed {
|
||||
for server in self.servers.lock().iter() {
|
||||
server.start_token().cancel();
|
||||
}
|
||||
}
|
||||
if allowed && !previous {
|
||||
for server in self.servers.lock().iter() {
|
||||
server.reset_for_restart();
|
||||
self.ensure_started(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reconfigure(&self) {
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
let servers = self.servers.lock().clone();
|
||||
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
|
||||
return;
|
||||
}
|
||||
self.stop_unlisted().await;
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
|
||||
@@ -92,7 +92,10 @@ impl LoadedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
|
||||
|
||||
pub struct PluginHost {
|
||||
project_policy: RwLock<ProjectPolicy>,
|
||||
engine: PluginEngine,
|
||||
plugins_dir: PathBuf,
|
||||
sink: Arc<dyn PluginEventSink>,
|
||||
@@ -116,6 +119,7 @@ impl PluginHost {
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
project_policy: RwLock::new(Arc::new(|_| false)),
|
||||
engine: PluginEngine::new().map_err(IdeError::internal)?,
|
||||
plugins_dir,
|
||||
sink,
|
||||
@@ -159,7 +163,30 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
/// Loads new dev plugins from `<root>/.openidea/plugins/` and unloads vanished ones.
|
||||
pub fn set_project_policy(&self, policy: ProjectPolicy) {
|
||||
*self.project_policy.write() = policy;
|
||||
}
|
||||
|
||||
pub fn restrict_project(&self, root: &Path) {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let ids: Vec<_> = self
|
||||
.plugins
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, p)| p.dev_root.as_deref() == Some(root))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids {
|
||||
self.unload(&id);
|
||||
}
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
|
||||
pub fn sync_project(&self, root: &Path) {
|
||||
if !(self.project_policy.read())(root) {
|
||||
self.restrict_project(root);
|
||||
return;
|
||||
}
|
||||
let _guard = self.lifecycle.lock();
|
||||
let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins"));
|
||||
let stale: Vec<String> = self
|
||||
@@ -448,7 +475,12 @@ impl PluginHost {
|
||||
// ---- internals ----
|
||||
|
||||
fn get(&self, id: &str) -> IdeResult<Arc<LoadedPlugin>> {
|
||||
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))
|
||||
let plugin =
|
||||
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?;
|
||||
if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) {
|
||||
return Err(IdeError::unavailable("Project is in Restricted Mode"));
|
||||
}
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
fn is_loaded_from(&self, dir: &Path) -> bool {
|
||||
|
||||
@@ -380,6 +380,7 @@ fn state_persists_across_restarts() {
|
||||
#[test]
|
||||
fn dev_plugins_follow_the_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.set_project_policy(Arc::new(|_| true));
|
||||
let dev = f.project.join(DEV_PLUGINS_DIR).join("toy");
|
||||
copy_dir(&sample("language-toy"), &dev);
|
||||
f.host.sync_project(&f.project);
|
||||
@@ -474,3 +475,38 @@ fn catalog_lists_sample_packages() {
|
||||
let x = catalog.iter().find(|c| c.id == "x").unwrap();
|
||||
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_dev_plugin_has_no_activation_registration() {
|
||||
let f = fixture(Limits::default());
|
||||
let dev = f.project.join(DEV_PLUGINS_DIR).join("hello");
|
||||
copy_dir(&sample("hello"), &dev);
|
||||
let manifest = dev.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap();
|
||||
std::fs::write(&manifest, text.split("[[contributes.commands]]").next().unwrap()).unwrap();
|
||||
assert!(PluginManifest::load(&dev).unwrap().contributes.commands.is_empty());
|
||||
let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let policy = trusted.clone();
|
||||
f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire)));
|
||||
f.host.sync_project(&f.project);
|
||||
let untrusted = f.host.list(Some(&f.project));
|
||||
assert!(
|
||||
!untrusted.plugins.iter().flat_map(|plugin| &plugin.commands).any(|command| command.id == "word-count"),
|
||||
"untrusted development plugin registered its activation witness"
|
||||
);
|
||||
assert!(untrusted.plugins.is_empty());
|
||||
assert!(f.host.info_of("hello").is_err());
|
||||
|
||||
// Positive control: activation itself registers these commands, not the manifest.
|
||||
trusted.store(true, std::sync::atomic::Ordering::Release);
|
||||
f.host.sync_project(&f.project);
|
||||
let info = f.host.info_of("hello").unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Active);
|
||||
assert!(info.commands.iter().any(|c| c.id == "word-count"));
|
||||
trusted.store(false, std::sync::atomic::Ordering::Release);
|
||||
assert!(f.host.reload("hello").is_err());
|
||||
assert!(f.host.set_enabled("hello", true).is_err());
|
||||
assert!(f.run("hello", "word-count", None).is_err());
|
||||
f.host.sync_project(&f.project);
|
||||
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ struct StopCommand {
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
project_root: PathBuf,
|
||||
id: RunId,
|
||||
config_id: String,
|
||||
name: String,
|
||||
@@ -337,7 +338,7 @@ impl ProcessManager {
|
||||
return Err(IdeError::invalid("Go build and run requires the output console"));
|
||||
}
|
||||
if extras.emulate_terminal {
|
||||
return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
|
||||
return self.start_pty(root, config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&inv.program);
|
||||
@@ -384,6 +385,7 @@ impl ProcessManager {
|
||||
_ => None,
|
||||
};
|
||||
let entry = Arc::new(Entry {
|
||||
project_root: root.to_path_buf(),
|
||||
id,
|
||||
config_id: config.id.clone(),
|
||||
name: config.name.clone(),
|
||||
@@ -444,6 +446,7 @@ impl ProcessManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_pty(
|
||||
&self,
|
||||
root: &Path,
|
||||
config: &RunConfig,
|
||||
inv: &Invocation,
|
||||
cwd: &Path,
|
||||
@@ -493,6 +496,7 @@ impl ProcessManager {
|
||||
|
||||
let id = RunId::next();
|
||||
let entry = Arc::new(Entry {
|
||||
project_root: root.to_path_buf(),
|
||||
id,
|
||||
config_id: config.id.clone(),
|
||||
name: config.name.clone(),
|
||||
@@ -640,6 +644,16 @@ impl ProcessManager {
|
||||
}
|
||||
|
||||
/// Kills every running process tree at once (shutting down).
|
||||
pub fn kill_project(&self, root: &Path) {
|
||||
for entry in self.runs.lock().values().filter(|e| e.project_root == root) {
|
||||
if !entry.exited.load(Ordering::Acquire)
|
||||
&& let Some(pid) = *entry.pid.lock()
|
||||
{
|
||||
kill_tree(pid, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kill_all(&self) {
|
||||
for entry in self.runs.lock().values() {
|
||||
entry.stopping.store(true, Ordering::Release);
|
||||
|
||||
@@ -474,6 +474,26 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoking_one_project_stops_only_its_runs() {
|
||||
let h = harness();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
let (mine, _) = start(&h, &cfg("mine", "/bin/sh", &["-c", "sleep 30"]));
|
||||
let theirs =
|
||||
h.pm.start(
|
||||
other.path(),
|
||||
&cfg("theirs", "/bin/sh", &["-c", "sleep 30"]),
|
||||
&StartOptions::default(),
|
||||
h.sink.clone(),
|
||||
Box::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
h.pm.kill_project(h.root.path());
|
||||
wait_exit(&h, mine, Duration::from_secs(5));
|
||||
assert!(h.pm.list().iter().any(|run| run.id == theirs.id && run.running));
|
||||
h.pm.stop(theirs.id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires CMake, CTest and a C++ compiler"]
|
||||
fn cmake_configure_discovers_builds_and_runs_a_multifile_target() {
|
||||
|
||||
@@ -202,7 +202,25 @@ pub(crate) fn run_paths(root: &Path, args: &[&str], paths: &[String]) -> IdeResu
|
||||
|
||||
/// Builds a `git` command with the environment every invocation needs.
|
||||
pub(crate) fn command(root: &Path) -> IdeResult<Command> {
|
||||
let mut cmd = Command::new(git_binary()?);
|
||||
let restricted = !crate::execution_allowed();
|
||||
let binary = if restricted {
|
||||
detected_git().ok_or_else(|| IdeError::unavailable("git executable not found"))?.to_path_buf()
|
||||
} else {
|
||||
git_binary()?
|
||||
};
|
||||
let mut cmd = Command::new(binary);
|
||||
if restricted {
|
||||
for setting in [
|
||||
"core.fsmonitor=false",
|
||||
"core.hooksPath=",
|
||||
"diff.external=",
|
||||
"core.attributesFile=",
|
||||
"submodule.recurse=false",
|
||||
] {
|
||||
cmd.arg("-c").arg(setting);
|
||||
}
|
||||
cmd.env_remove("GIT_EXTERNAL_DIFF").env("GIT_NO_LAZY_FETCH", "1");
|
||||
}
|
||||
cmd.current_dir(root)
|
||||
.arg("-c")
|
||||
.arg("core.quotepath=off")
|
||||
|
||||
@@ -484,8 +484,16 @@ pub use roots::Repos;
|
||||
pub fn clone(url: &str, dest: &Path, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let parent = dest.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let dest = if dest.is_absolute() { dest.to_path_buf() } else { std::env::current_dir()?.join(dest) };
|
||||
let dest = dest.to_string_lossy();
|
||||
cli::run_streaming(parent, &["clone", "--progress", "--", url, &dest], cancel, on_output)
|
||||
// Cloning must not inherit an enclosing project's executable Git settings.
|
||||
let cwd = tempfile::tempdir()?;
|
||||
cli::run_streaming(
|
||||
cwd.path(),
|
||||
&["-c", "core.hooksPath=", "clone", "--progress", "--", url, &dest],
|
||||
cancel,
|
||||
on_output,
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates an empty repository in `dir` (`git init`).
|
||||
@@ -502,6 +510,7 @@ pub fn diff_hunks(old_text: &str, new_text: &str) -> Vec<LineHunk> {
|
||||
/// An open git repository (worktree). Cheap to keep around; all reads re-check the
|
||||
/// on-disk state, so it never needs to be reopened after external changes.
|
||||
pub struct Repo {
|
||||
execution_allowed: std::sync::atomic::AtomicBool,
|
||||
root: PathBuf,
|
||||
git_dir: PathBuf,
|
||||
inner: std::sync::Mutex<repo::Cache>,
|
||||
@@ -515,27 +524,33 @@ impl Repo {
|
||||
}
|
||||
/// The worktree root.
|
||||
pub fn root(&self) -> &Path {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
&self.root
|
||||
}
|
||||
/// Untracked files with one of these names (or under such a folder) are not reported.
|
||||
pub fn set_ignored_names(&self, names: IgnoredNames) {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
*self.ignored.write().unwrap_or_else(|p| p.into_inner()) = names;
|
||||
}
|
||||
/// Staged (HEAD↔index) and unstaged (index↔worktree, incl. untracked) changes,
|
||||
/// sorted by path. Conflicted files report `unstaged: Conflicted` and no staged kind.
|
||||
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
status::status(self)
|
||||
}
|
||||
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
refs::branch_info(self)
|
||||
}
|
||||
/// Current branch (short name; `None` when detached) and HEAD commit, without
|
||||
/// upstream tracking (cheap enough to call for every repository on each refresh).
|
||||
pub fn head(&self) -> IdeResult<(Option<String>, Option<String>)> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
refs::head(&self.gix()?)
|
||||
}
|
||||
/// Merge / rebase / cherry-pick… in progress (cheap: no ref walking).
|
||||
pub fn operation(&self) -> IdeResult<Option<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
Ok(refs::operation(&self.gix()?))
|
||||
}
|
||||
/// Content of `path` at `rev` (default HEAD); `None` if absent there.
|
||||
@@ -547,12 +562,14 @@ impl Repo {
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let encoding = encoding.into();
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let rel = self.rel_path(path)?;
|
||||
let repo = self.gix()?;
|
||||
Ok(repo::blob_at(&repo, &rel, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
|
||||
}
|
||||
/// Raw revision bytes, for encoding detection when the working file is absent.
|
||||
pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<Vec<u8>>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
repo::blob_at(&self.gix()?, &self.rel_path(path)?, rev.unwrap_or("HEAD"))
|
||||
}
|
||||
/// Stage selected decoded hunks, retaining the index encoding, BOM and line endings.
|
||||
@@ -564,6 +581,7 @@ impl Repo {
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<()> {
|
||||
let encoding = encoding.into();
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::stage_hunks(self, path, current, selected, encoding)
|
||||
}
|
||||
/// Hunks of `current_text` against HEAD (or the index when `against_index`).
|
||||
@@ -580,6 +598,7 @@ impl Repo {
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<Vec<LineHunk>> {
|
||||
let encoding = encoding.into();
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let Ok(rel) = self.rel_path(path) else { return Ok(Vec::new()) };
|
||||
if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
|
||||
return Ok(Vec::new());
|
||||
@@ -609,6 +628,7 @@ impl Repo {
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let encoding = encoding.into();
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::blame(self, path, current_text, revision, encoding)
|
||||
}
|
||||
/// Commits newest-first in topological order (children before parents).
|
||||
@@ -619,43 +639,52 @@ impl Repo {
|
||||
/// `skip`/`limit` paginate over the filtered result; `limit == 0` means no limit.
|
||||
/// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: <name>`.
|
||||
pub fn log(&self, query: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
history::log(self, query)
|
||||
}
|
||||
/// Files changed by `commit` relative to its first parent (renames detected).
|
||||
pub fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
history::commit_files(self, commit)
|
||||
}
|
||||
/// Combined changes of several commits (oldest first): per file, before the first commit
|
||||
/// that touched it ↔ after the last one (IDEA's multi-commit Git Log selection).
|
||||
pub fn commits_changes(&self, commits: &[String]) -> IdeResult<Vec<RevisionChange>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
history::commits_changes(self, commits)
|
||||
}
|
||||
/// Local and remote branches; the current branch first, then most recent first.
|
||||
pub fn branches(&self) -> IdeResult<Vec<Branch>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
refs::branches(self)
|
||||
}
|
||||
/// Stash entries, newest (`stash@{0}`) first.
|
||||
pub fn stashes(&self) -> IdeResult<Vec<Stash>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
refs::stashes(self)
|
||||
}
|
||||
/// Unified diff of a file: index↔worktree (`staged == false`, untracked files diff
|
||||
/// against /dev/null) or HEAD↔index (`staged == true`). Empty when unchanged.
|
||||
pub fn diff_text(&self, path: &Path, staged: bool, encoding: impl Into<TextEncoding>) -> IdeResult<String> {
|
||||
let encoding = encoding.into();
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::diff_text(self, path, staged, encoding)
|
||||
}
|
||||
|
||||
/// `git config --get <key>` (all scopes); `None` when unset.
|
||||
pub fn config_get(&self, key: &str) -> IdeResult<Option<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let out = cli::run_raw(&self.root, &["config", "--get", key], None)?;
|
||||
Ok((out.code == Some(0)).then(|| out.stdout_str().trim().to_string()))
|
||||
}
|
||||
|
||||
// ---- writes (git CLI) ----
|
||||
pub fn stage(&self, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::stage(self, paths)
|
||||
}
|
||||
pub fn unstage(&self, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::unstage(self, paths)
|
||||
}
|
||||
/// Applies a unified-diff patch to the index (partial staging of hunks/lines).
|
||||
@@ -665,16 +694,19 @@ impl Repo {
|
||||
patch: &str,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<E>,
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::stage_patch(self, patch, &|path| encoding(path).map(Into::into))
|
||||
}
|
||||
/// Discards unstaged changes: tracked paths are restored from the index (staged
|
||||
/// changes are kept), untracked files and directories are deleted.
|
||||
pub fn discard(&self, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::discard(self, paths)
|
||||
}
|
||||
/// Commits the index; returns the new HEAD commit id. An empty message with
|
||||
/// `amend` keeps the previous message.
|
||||
pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult<String> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() };
|
||||
write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8.into()))
|
||||
}
|
||||
@@ -685,12 +717,14 @@ impl Repo {
|
||||
options: &CommitOptions,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<E>,
|
||||
) -> IdeResult<String> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::commit(self, message, options, &|path| encoding(path).map(Into::into))
|
||||
}
|
||||
/// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch
|
||||
/// (`origin/x`) gets a local tracking branch and any other revision is checked out
|
||||
/// detached.
|
||||
pub fn checkout(&self, branch: &str, create: bool) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::checkout(self, branch, create)
|
||||
}
|
||||
/// [`Repo::checkout`] as IDEA does it: local changes in the way are reported (or
|
||||
@@ -698,62 +732,75 @@ impl Repo {
|
||||
/// is behind fast-forwards that branch, while one that is ahead is reported (or
|
||||
/// rebased / reset per `options.diverged`).
|
||||
pub fn checkout_with(&self, branch: &str, create: bool, options: &CheckoutOptions) -> IdeResult<CheckoutOutcome> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::checkout_with(self, branch, create, options)
|
||||
}
|
||||
/// Creates branch `name` at `start` (default HEAD), optionally switching to it. A
|
||||
/// remote-tracking start point (`origin/x`) becomes the new branch's upstream.
|
||||
pub fn create_branch(&self, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::create_branch(self, name, start, checkout)
|
||||
}
|
||||
/// Undo Commit: resets the current branch to the parent of `commit` (which must be
|
||||
/// HEAD and not a merge) keeping its changes staged; undoing the initial commit
|
||||
/// leaves an unborn branch. Returns the undone commit's message.
|
||||
pub fn undo_commit(&self, commit: &str) -> IdeResult<String> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::undo_commit(self, commit)
|
||||
}
|
||||
/// Deletes a local branch (`git branch -d`, `-D` when `force`).
|
||||
pub fn delete_branch(&self, branch: &str, force: bool) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::delete_branch(self, branch, force)
|
||||
}
|
||||
/// Deletes a local branch as IDEA does: without asking, force-deleting one that is not
|
||||
/// fully merged (reported as `unmerged`), and returning what [`Repo::restore_branch`]
|
||||
/// needs to undo it.
|
||||
pub fn delete_branch_restorable(&self, branch: &str) -> IdeResult<DeletedBranch> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::delete_branch_restorable(self, branch)
|
||||
}
|
||||
/// Recreates a deleted branch at its old tip, with its upstream.
|
||||
pub fn restore_branch(&self, deleted: &DeletedBranch) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::restore_branch(self, deleted)
|
||||
}
|
||||
/// Renames a local branch (`git branch -m`); its upstream configuration moves with it.
|
||||
pub fn rename_branch(&self, from: &str, to: &str) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::rename_branch(self, from, to)
|
||||
}
|
||||
/// Merges `branch` into HEAD; local changes are stashed and restored around it. On
|
||||
/// conflicts an error is returned and the repository is left mid-merge
|
||||
/// (`branch_info().operation == Some("merge")`).
|
||||
pub fn merge(&self, branch: &str) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::merge(self, branch)
|
||||
}
|
||||
/// Rebases the current branch onto `onto`; local changes are stashed and restored.
|
||||
pub fn rebase(&self, onto: &str) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::rebase(self, onto)
|
||||
}
|
||||
/// `keep_index`: changes already staged stay in the index (and the working tree).
|
||||
pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::stash_push(self, message, include_untracked, keep_index)
|
||||
}
|
||||
/// `reinstate_index`: also restore what was staged when the stash was made.
|
||||
pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::stash_apply(self, index, pop, reinstate_index)
|
||||
}
|
||||
/// Creates and checks out `branch` at the commit the stash was made on, applies the
|
||||
/// stash there (index included) and drops it.
|
||||
pub fn stash_branch(&self, index: u32, branch: &str) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::stash_branch(self, index, branch)
|
||||
}
|
||||
/// Removes `stash@{index}` without applying it.
|
||||
pub fn stash_drop(&self, index: u32) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::stash_drop(self, index)
|
||||
}
|
||||
/// Deletes a branch on a remote (`git push <remote> --delete <branch>`); `branch` is the
|
||||
@@ -764,16 +811,19 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::delete_remote_branch(self, branch, cancel, on_output)
|
||||
}
|
||||
/// Network operations stream progress lines through `on_output`.
|
||||
/// Fetches all remotes (pruning deleted branches).
|
||||
pub fn fetch(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
cli::run_streaming(&self.root, &["fetch", "--progress", "--all", "--prune"], cancel, on_output)
|
||||
}
|
||||
/// Update Project: pulls the upstream by merge or rebase. Local changes are stashed
|
||||
/// and restored; the merge mode fast-forwards when possible whatever `pull.ff` says.
|
||||
pub fn pull(&self, rebase: bool, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let mode: &[&str] = if rebase { &["--rebase"] } else { &["--no-rebase", "--ff"] };
|
||||
let mut args = vec!["pull", "--progress", "--autostash"];
|
||||
args.extend_from_slice(mode);
|
||||
@@ -788,6 +838,7 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::push(self, force_with_lease, set_upstream, None, cancel, on_output)
|
||||
}
|
||||
/// [`Repo::push`] that also pushes tags (Push dialog › Push tags).
|
||||
@@ -799,6 +850,7 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::push(self, force_with_lease, set_upstream, tags, cancel, on_output)
|
||||
}
|
||||
/// Pushes a local branch, or its history up to `revision`, to its upstream (Git Log
|
||||
@@ -811,6 +863,7 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::push_branch(
|
||||
self,
|
||||
branch,
|
||||
@@ -829,11 +882,13 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::push_branch(self, branch, options, cancel, on_output)
|
||||
}
|
||||
/// Fast-forwards a local branch that is not checked out to its upstream (Branches ›
|
||||
/// Update). Fails when the branch has diverged.
|
||||
pub fn update_branch(&self, branch: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::update_branch(self, branch, cancel, on_output)
|
||||
}
|
||||
// ---- in-progress operations, history rewriting, tags ----
|
||||
@@ -841,13 +896,16 @@ impl Repo {
|
||||
/// Continues the merge / rebase / cherry-pick / revert in progress; fails while
|
||||
/// conflicts are unresolved.
|
||||
pub fn continue_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::continue_op(self, cancel, on_output)
|
||||
}
|
||||
pub fn abort_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::abort_op(self, cancel, on_output)
|
||||
}
|
||||
/// Skips the current commit of a rebase / cherry-pick / revert.
|
||||
pub fn skip_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::skip_op(self, cancel, on_output)
|
||||
}
|
||||
/// Cherry-picks `commits` in order (oldest first). Stops mid-way on conflicts.
|
||||
@@ -857,10 +915,12 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::cherry_pick(self, commits, cancel, on_output)
|
||||
}
|
||||
/// Reverts `commits` in order (newest first) with git's default messages.
|
||||
pub fn revert(&self, commits: &[String], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::revert(self, commits, cancel, on_output)
|
||||
}
|
||||
/// Interactive rebase of `base..HEAD` (`None`: from the root) following `entries`,
|
||||
@@ -873,6 +933,7 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::interactive_rebase(self, base, entries, cancel, on_output)
|
||||
}
|
||||
/// Moves the current branch to `commit` (`git reset --<mode>`).
|
||||
@@ -883,26 +944,32 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::reset(self, commit, mode, cancel, on_output)
|
||||
}
|
||||
/// Creates tag `name` on `commit` (annotated when `message` is given).
|
||||
pub fn create_tag(&self, name: &str, commit: &str, message: Option<&str>, force: bool) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::create_tag(self, name, commit, message, force)
|
||||
}
|
||||
pub fn tags(&self) -> IdeResult<Vec<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::tags(self)
|
||||
}
|
||||
/// Files that differ between revisions `from` and `to`.
|
||||
pub fn diff_revisions(&self, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::diff_revisions(self, from, to)
|
||||
}
|
||||
/// Tracked files whose working-tree content differs from `rev`.
|
||||
pub fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::diff_with_worktree(self, rev)
|
||||
}
|
||||
/// `(repo-relative path, added lines, deleted lines)` of the tracked files whose
|
||||
/// working-tree content differs from `rev`.
|
||||
pub fn numstat_with_worktree(&self, rev: &str) -> IdeResult<Vec<(String, u32, u32)>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::numstat_with_worktree(self, rev)
|
||||
}
|
||||
|
||||
@@ -910,37 +977,45 @@ impl Repo {
|
||||
|
||||
/// Patch of the local changes of `paths` against HEAD (untracked files included).
|
||||
pub fn create_patch(&self, paths: &[PathBuf], reverse: bool) -> IdeResult<String> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let rel = self.rel_paths(paths)?;
|
||||
Ok(patch::working_patch(self, &rel, reverse, true)?.0)
|
||||
}
|
||||
/// Patch of the changes made by `commits` (oldest first).
|
||||
pub fn create_commits_patch(&self, commits: &[String], reverse: bool) -> IdeResult<String> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
patch::commits_patch(self, commits, reverse)
|
||||
}
|
||||
pub fn patch_preview(&self, patch: &str) -> IdeResult<Vec<PatchFile>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
patch::preview(self, patch)
|
||||
}
|
||||
/// Applies a patch to the working tree, 3-way merging when it does not apply cleanly.
|
||||
pub fn apply_patch(&self, patch: &str) -> IdeResult<ApplyOutcome> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
patch::apply(self, patch)
|
||||
}
|
||||
/// Saves the changes of `paths` under `shelf_dir` and reverts them.
|
||||
pub fn shelve(&self, shelf_dir: &Path, name: &str, paths: &[PathBuf]) -> IdeResult<ShelvedChangeList> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let rel = self.rel_paths(paths)?;
|
||||
shelf::shelve(self, shelf_dir, name, &rel)
|
||||
}
|
||||
pub fn unshelve(&self, shelf_dir: &Path, id: &str, remove: bool) -> IdeResult<ApplyOutcome> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
shelf::unshelve(self, shelf_dir, id, remove)
|
||||
}
|
||||
|
||||
/// Who is on each side of the conflicts of the operation in progress.
|
||||
pub fn merge_sides(&self) -> IdeResult<MergeSides> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::merge_sides(self)
|
||||
}
|
||||
/// The versions of a conflicted file with the user's work as `ours`, as IDEA's merge
|
||||
/// dialog shows them (sides swapped during a rebase).
|
||||
pub fn merge_versions(&self, path: &Path, encoding: impl Into<TextEncoding>) -> IdeResult<MergeVersions> {
|
||||
let encoding = encoding.into();
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let (base, ours, theirs) = self.conflict_versions(path, encoding)?;
|
||||
let sides = self.merge_sides()?;
|
||||
let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) };
|
||||
@@ -957,6 +1032,7 @@ impl Repo {
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<(Option<String>, Option<String>, Option<String>)> {
|
||||
let encoding = encoding.into();
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let rel = self.rel_path(path)?;
|
||||
let repo = self.gix()?;
|
||||
let stage = |n: u8| -> IdeResult<Option<String>> {
|
||||
@@ -965,3 +1041,25 @@ impl Repo {
|
||||
Ok((stage(1)?, stage(2)?, stage(3)?))
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! { static EXECUTION_ALLOWED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) }; }
|
||||
struct ExecutionScope(bool);
|
||||
impl ExecutionScope {
|
||||
fn enter(allowed: bool) -> Self {
|
||||
Self(EXECUTION_ALLOWED.with(|current| current.replace(current.get() && allowed)))
|
||||
}
|
||||
}
|
||||
impl Drop for ExecutionScope {
|
||||
fn drop(&mut self) {
|
||||
EXECUTION_ALLOWED.with(|current| current.set(self.0));
|
||||
}
|
||||
}
|
||||
pub(crate) fn execution_allowed() -> bool {
|
||||
EXECUTION_ALLOWED.with(|current| current.get())
|
||||
}
|
||||
impl Repo {
|
||||
pub fn set_execution_allowed(&self, allowed: bool) {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
self.execution_allowed.store(allowed, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ pub(crate) fn parse_worktrees(text: &str) -> Vec<Worktree> {
|
||||
impl Repo {
|
||||
/// Merges `branches` into the current branch (Git › Merge…).
|
||||
pub fn merge_with(&self, branches: &[String], options: &MergeOptions) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let args = merge_args(branches, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
run(self.root(), &args, None).map(|_| ())
|
||||
@@ -217,6 +218,7 @@ impl Repo {
|
||||
|
||||
/// Rebases the current branch onto `upstream` (or `upstream..HEAD` onto `options.onto`).
|
||||
pub fn rebase_with(&self, upstream: &str, options: &RebaseOptions) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let args = rebase_args(upstream, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
run(self.root(), &args, None).map(|_| ())
|
||||
@@ -232,6 +234,7 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let args = pull_args(remote, branches, rebase, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
cli::run_streaming(self.root(), &args, cancel, on_output)
|
||||
@@ -239,6 +242,7 @@ impl Repo {
|
||||
|
||||
/// Manage Remotes: adds, removes or edits a remote.
|
||||
pub fn edit_remote(&self, edit: &RemoteEdit) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
match edit {
|
||||
RemoteEdit::Add { name, url } => {
|
||||
let (name, url) = (arg(name, "remote name")?, arg(url, "URL")?);
|
||||
@@ -262,6 +266,7 @@ impl Repo {
|
||||
|
||||
/// Working trees of the repository, the main one first.
|
||||
pub fn worktrees(&self) -> IdeResult<Vec<Worktree>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let out = run(self.root(), &["worktree", "list", "--porcelain"], None)?;
|
||||
Ok(parse_worktrees(&out.stdout_str()))
|
||||
}
|
||||
@@ -269,6 +274,7 @@ impl Repo {
|
||||
/// Git › New Worktree…: checks out `commitish` at `path`, on a new branch `new_branch`
|
||||
/// when given (otherwise `commitish` must be a branch not checked out elsewhere).
|
||||
pub fn add_worktree(&self, path: &Path, commitish: &str, new_branch: Option<&str>) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let commitish = arg(commitish, "revision")?;
|
||||
let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
|
||||
@@ -281,6 +287,7 @@ impl Repo {
|
||||
}
|
||||
|
||||
pub fn remove_worktree(&self, path: &Path, force: bool) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let mut args = vec!["worktree", "remove"];
|
||||
if force {
|
||||
|
||||
+36
-10
@@ -70,16 +70,9 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult<MergeSides> {
|
||||
/// Three-way merge of file contents (`git merge-file --diff3`): changes that don't
|
||||
/// conflict are merged, each conflict is left as a diff3 block.
|
||||
pub(crate) fn merge_file(r: &Repo, ours: &str, base: &str, theirs: &str) -> IdeResult<String> {
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let dir = TempDir(r.git_dir.join(format!("openidea-merge-{}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir.0)?;
|
||||
let dir = tempfile::tempdir()?;
|
||||
let file = |name: &str, text: &str| -> IdeResult<String> {
|
||||
let path = dir.0.join(name);
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, text)?;
|
||||
Ok(path.to_string_lossy().into_owned())
|
||||
};
|
||||
@@ -345,6 +338,38 @@ pub(crate) fn tags(r: &Repo) -> IdeResult<Vec<String>> {
|
||||
/// Tracked files that differ between `rev` and the working tree (Compare with Local).
|
||||
pub(crate) fn diff_with_worktree(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
check_commits(&[rev.to_string()])?;
|
||||
if !crate::execution_allowed() {
|
||||
let out = run(
|
||||
r.root(),
|
||||
&["diff", "--cached", "--name-status", "-z", "--no-ext-diff", "--no-textconv", rev, "--"],
|
||||
None,
|
||||
)?;
|
||||
let mut paths: std::collections::BTreeSet<String> =
|
||||
parse_name_status(&out.stdout_str()).into_iter().map(|c| c.path).collect();
|
||||
paths.extend(r.status()?.into_iter().filter(|s| s.unstaged != Some(ChangeKind::Untracked)).map(|s| s.rel_path));
|
||||
let repo = r.gix()?;
|
||||
let mut changes = Vec::new();
|
||||
for path in paths {
|
||||
let old = crate::repo::blob_at(&repo, &path, rev)?;
|
||||
let new = match std::fs::read(r.abs_path(&path)) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
let kind = if old.is_none() {
|
||||
ChangeKind::Added
|
||||
} else if new.is_none() {
|
||||
ChangeKind::Deleted
|
||||
} else {
|
||||
ChangeKind::Modified
|
||||
};
|
||||
changes.push(ChangedFile { path, kind, old_path: None });
|
||||
}
|
||||
return Ok(changes);
|
||||
}
|
||||
let out = run(r.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", rev, "--"], None)?;
|
||||
Ok(parse_name_status(&out.stdout_str()))
|
||||
}
|
||||
@@ -372,7 +397,8 @@ pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> {
|
||||
/// Files that differ between two revisions (Update Project's "N files updated").
|
||||
pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
check_commits(&[from.to_string(), to.to_string()])?;
|
||||
let out = run(r.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", from, to, "--"], None)?;
|
||||
let out =
|
||||
run(r.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to, "--"], None)?;
|
||||
Ok(parse_name_status(&out.stdout_str()))
|
||||
}
|
||||
|
||||
|
||||
@@ -40,12 +40,14 @@ pub(crate) fn parse_remotes(text: &str) -> Vec<Remote> {
|
||||
impl Repo {
|
||||
/// Configured remotes with their fetch URLs.
|
||||
pub fn remotes(&self) -> IdeResult<Vec<Remote>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let out = cli::run(self.root(), &["remote", "-v"], None)?;
|
||||
Ok(parse_remotes(&out.stdout_str()))
|
||||
}
|
||||
|
||||
/// Whether `rev` names a commit present in the object database.
|
||||
pub fn has_commit(&self, rev: &str) -> bool {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
check_rev(rev).is_ok()
|
||||
&& cli::run_raw(self.root(), &["cat-file", "-e", &format!("{rev}^{{commit}}")], None)
|
||||
.is_ok_and(|o| o.code == Some(0))
|
||||
@@ -60,6 +62,7 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
check_rev(remote)?;
|
||||
for r in refspecs {
|
||||
check_rev(r)?;
|
||||
@@ -71,6 +74,7 @@ impl Repo {
|
||||
|
||||
/// The best common ancestor of two revisions, if any.
|
||||
pub fn merge_base(&self, a: &str, b: &str) -> IdeResult<Option<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
check_rev(a)?;
|
||||
check_rev(b)?;
|
||||
let out = cli::run_raw(self.root(), &["merge-base", a, b], None)?;
|
||||
@@ -79,9 +83,14 @@ impl Repo {
|
||||
|
||||
/// Files changed between two revisions (renames detected).
|
||||
pub fn diff_files(&self, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
check_rev(from)?;
|
||||
check_rev(to)?;
|
||||
let out = cli::run(self.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", from, to, "--"], None)?;
|
||||
let out = cli::run(
|
||||
self.root(),
|
||||
&["diff", "--name-status", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to, "--"],
|
||||
None,
|
||||
)?;
|
||||
Ok(crate::ops::parse_name_status(&out.stdout_str()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,13 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let common_dir = absolute(repo.common_dir())?;
|
||||
let files = config_files(&git_dir, &common_dir);
|
||||
let cache = Cache { config_stamp: stamp(&files), config_files: files, repo: ts };
|
||||
Ok(Some(Repo { root, git_dir, inner: std::sync::Mutex::new(cache), ignored: Default::default() }))
|
||||
Ok(Some(Repo {
|
||||
execution_allowed: std::sync::atomic::AtomicBool::new(true),
|
||||
root,
|
||||
git_dir,
|
||||
inner: std::sync::Mutex::new(cache),
|
||||
ignored: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn absolute(p: &Path) -> IdeResult<PathBuf> {
|
||||
@@ -70,6 +76,11 @@ impl Repo {
|
||||
/// A thread-local gitoxide handle; reopened when the git configuration changed
|
||||
/// on disk (e.g. after `push --set-upstream` or `git config` in a terminal).
|
||||
pub(crate) fn gix(&self) -> IdeResult<gix::Repository> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
if !self.execution_allowed.load(std::sync::atomic::Ordering::Acquire) {
|
||||
return gix::open_opts(&self.git_dir, gix::open::Options::default().filter_config_section(|_| false))
|
||||
.map_err(gx);
|
||||
}
|
||||
let mut cache = self.inner.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let now = stamp(&cache.config_files);
|
||||
if now != cache.config_stamp {
|
||||
@@ -89,6 +100,7 @@ impl Repo {
|
||||
/// Repository-relative path with `/` separators. Accepts absolute paths inside the
|
||||
/// worktree or paths already relative to it.
|
||||
pub(crate) fn rel_path(&self, path: &Path) -> IdeResult<String> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let rel = if path.is_absolute() {
|
||||
self.strip_root(path)
|
||||
.ok_or_else(|| IdeError::invalid(format!("{} is outside the repository", path.display())))?
|
||||
@@ -140,6 +152,7 @@ impl Repo {
|
||||
|
||||
/// Absolute path for a repository-relative path.
|
||||
pub(crate) fn abs_path(&self, rel: &str) -> PathBuf {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let mut p = self.root.clone();
|
||||
for part in rel.split('/').filter(|s| !s.is_empty()) {
|
||||
p.push(part);
|
||||
|
||||
@@ -16,6 +16,7 @@ const UNCOMMITTED_AUTHOR: &str = "Not Committed Yet";
|
||||
|
||||
impl Repo {
|
||||
pub(crate) fn rel_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
@@ -26,6 +27,7 @@ impl Repo {
|
||||
}
|
||||
|
||||
pub(crate) fn has_head(&self) -> IdeResult<bool> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let repo = self.gix()?;
|
||||
Ok(!repo.head().map_err(gx)?.is_unborn())
|
||||
}
|
||||
@@ -86,11 +88,17 @@ pub(crate) fn blame(
|
||||
if !tracked_in_head {
|
||||
return Ok(uncommitted_lines(line_count));
|
||||
}
|
||||
let out = run(
|
||||
r.root(),
|
||||
&["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel],
|
||||
Some(&contents),
|
||||
)?;
|
||||
// Restricted blame reads committed blobs only. Feeding working-tree bytes to
|
||||
// Git's --contents path would run configured clean filters.
|
||||
let out = if crate::execution_allowed() {
|
||||
run(
|
||||
r.root(),
|
||||
&["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel],
|
||||
Some(&contents),
|
||||
)?
|
||||
} else {
|
||||
run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)?
|
||||
};
|
||||
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
|
||||
for line in &mut lines {
|
||||
if line.commit.bytes().all(|b| b == b'0') {
|
||||
@@ -98,6 +106,14 @@ pub(crate) fn blame(
|
||||
line.summary = String::new();
|
||||
}
|
||||
}
|
||||
if !crate::execution_allowed() {
|
||||
let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default();
|
||||
for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() {
|
||||
let start = (hunk.old_start as usize).min(lines.len());
|
||||
let end = (start + hunk.old_lines as usize).min(lines.len());
|
||||
lines.splice(start..end, uncommitted_lines(hunk.lines as usize));
|
||||
}
|
||||
}
|
||||
// Defensive: always exactly one entry per line.
|
||||
lines.truncate(line_count);
|
||||
while lines.len() < line_count {
|
||||
@@ -161,6 +177,16 @@ fn uncommitted_lines(n: usize) -> Vec<BlameLine> {
|
||||
|
||||
pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult<String> {
|
||||
let rel = r.rel_path(path)?;
|
||||
if !crate::execution_allowed() {
|
||||
let old = r.file_at(path, Some(if staged { "HEAD" } else { ":0" }), encoding)?.unwrap_or_default();
|
||||
let new = if staged {
|
||||
r.file_at(path, Some(":0"), encoding)?.unwrap_or_default()
|
||||
} else {
|
||||
std::fs::read(r.abs_path(&rel)).map(|b| decode(&b, encoding).into_owned()).unwrap_or_default()
|
||||
};
|
||||
let selected: Vec<_> = (0..crate::diff::line_hunks(&old, &new).len()).collect();
|
||||
return Ok(crate::diff::build_partial_patch(&rel, &old, &new, &selected));
|
||||
}
|
||||
let base =
|
||||
["-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false", "-c", "diff.external=", "--literal-pathspecs"];
|
||||
let in_index = {
|
||||
@@ -221,7 +247,11 @@ pub(crate) fn stage_patch(r: &Repo, patch: &str, encoding: &dyn Fn(&Path) -> Ide
|
||||
|
||||
/// Paths with unresolved conflicts (index stages 1–3).
|
||||
pub(crate) fn unmerged_paths(r: &Repo) -> IdeResult<Vec<String>> {
|
||||
let out = run(r.root(), &["diff", "--name-only", "--diff-filter=U", "-z"], None)?;
|
||||
let out = run(
|
||||
r.root(),
|
||||
&["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=U", "-z"],
|
||||
None,
|
||||
)?;
|
||||
Ok(out.stdout_str().split('\0').filter(|s| !s.is_empty()).map(str::to_string).collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -1534,3 +1534,54 @@ fn gbk_text_patch_keeps_utf8_paths_and_encoded_payload() {
|
||||
assert!(repo.stage_patch(&patch, &|_| Ok(encoding_rs::GBK)).is_err());
|
||||
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), encoding_rs::GBK.encode(new).0.as_ref());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn restricted_reads_ignore_executable_repository_configuration() {
|
||||
let f = Fixture::new();
|
||||
f.write("a.txt", "one\ntwo\n");
|
||||
f.commit_all("initial");
|
||||
f.write(".gitattributes", "*.txt filter=evil diff=evil\n");
|
||||
f.write("a.txt", "one\nchanged\n");
|
||||
for key in ["filter.evil.clean", "diff.evil.textconv", "diff.external", "core.fsmonitor"] {
|
||||
f.git(&["config", key, "touch execution.marker; cat"]);
|
||||
}
|
||||
let repo = f.repo();
|
||||
repo.set_execution_allowed(false);
|
||||
assert!(!repo.status().unwrap().is_empty());
|
||||
assert!(repo.diff_text(&f.path("a.txt"), false, encoding_rs::UTF_8).unwrap().contains("changed"));
|
||||
assert_eq!(repo.blame(&f.path("a.txt"), None, None, encoding_rs::UTF_8).unwrap().len(), 2);
|
||||
assert!(!repo.diff_with_worktree("HEAD").unwrap().is_empty());
|
||||
assert!(!repo.log(&LogQuery::default()).unwrap().is_empty());
|
||||
repo.merge_versions(&f.path("a.txt"), encoding_rs::UTF_8).unwrap();
|
||||
assert!(!f.path("execution.marker").exists());
|
||||
// Positive control: the same filter really executes without restriction.
|
||||
f.git(&["-c", "core.fsmonitor=false", "diff", "--no-ext-diff", "--no-textconv"]);
|
||||
assert!(f.path("execution.marker").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn restricted_conflict_preview_uses_host_owned_temporary_files() {
|
||||
let f = Fixture::new();
|
||||
f.write("a.txt", "base\n");
|
||||
f.commit_all("base");
|
||||
f.git(&["checkout", "-b", "other"]);
|
||||
f.write("a.txt", "other\n");
|
||||
f.commit_all("other");
|
||||
f.git(&["checkout", "main"]);
|
||||
f.write("a.txt", "mine\n");
|
||||
f.commit_all("mine");
|
||||
let output = Command::new("git").current_dir(&f.dir).args(["merge", "other"]).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
let trap = f.path(&format!(".git/openidea-merge-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&trap).unwrap();
|
||||
let marker = f._tmp.path().join("user-data");
|
||||
std::fs::write(&marker, "untouched").unwrap();
|
||||
std::os::unix::fs::symlink(&marker, trap.join("ours")).unwrap();
|
||||
let repo = f.repo();
|
||||
repo.set_execution_allowed(false);
|
||||
let versions = repo.merge_versions(&f.path("a.txt"), encoding_rs::UTF_8).unwrap();
|
||||
assert!(versions.merged.unwrap().contains("<<<<<<<"));
|
||||
assert_eq!(std::fs::read_to_string(marker).unwrap(), "untouched");
|
||||
}
|
||||
|
||||
@@ -266,3 +266,41 @@ pub fn log_frontend(level: LogLevel, message: String) {
|
||||
LogLevel::Error => tracing::error!(target: "frontend", "{message}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn project_trust_status(state: State<'_, AppState>, path: PathBuf) -> IdeResult<ide_host::trust::TrustStatus> {
|
||||
state.trust.status(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn project_trust_list(state: State<'_, AppState>) -> IdeResult<ide_host::trust::TrustDecisions> {
|
||||
state.trust.decisions()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn project_trust_set(
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
trusted: bool,
|
||||
folder: bool,
|
||||
) -> IdeResult<()> {
|
||||
state.host.set_project_trust(&path, trusted, folder).await?;
|
||||
if !trusted {
|
||||
state.plugins.restrict_project(&dunce::canonicalize(&path).unwrap_or(path));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn project_trust_remove_location(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
|
||||
state.trust.remove_location(&path)?;
|
||||
state.host.refresh_project_trust().await?;
|
||||
for project in state.host.projects().into_iter().filter(|p| !p.is_trusted()) {
|
||||
state.plugins.restrict_project(&project.root);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ pub async fn coverage_prepare(
|
||||
/// Reads the report a coverage run left behind.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn coverage_collect(state: State<'_, AppState>, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
ide_host::ops::coverage::coverage_collect(&state.host, "", report).await
|
||||
pub async fn coverage_collect(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
report: CoverageReportSpec,
|
||||
) -> IdeResult<CoverageSuite> {
|
||||
ide_host::ops::coverage::coverage_collect(&state.host, window.label(), report).await
|
||||
}
|
||||
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
|
||||
|
||||
@@ -64,6 +64,10 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::recent_projects,
|
||||
app::remove_recent_project,
|
||||
app::open_project,
|
||||
app::project_trust_status,
|
||||
app::project_trust_list,
|
||||
app::project_trust_set,
|
||||
app::project_trust_remove_location,
|
||||
workspace::workspace_create,
|
||||
workspace::workspace_attach,
|
||||
workspace::workspace_update,
|
||||
|
||||
@@ -36,8 +36,16 @@ fn deliver(client: Option<&str>, event: PluginEvent) {
|
||||
}
|
||||
|
||||
/// Creates the host over `<app-data>/plugins` and loads installed plugins in the background.
|
||||
pub fn create_host(_app: &AppHandle, data_dir: &Path) -> IdeResult<Arc<PluginHost>> {
|
||||
pub fn create_host(
|
||||
_app: &AppHandle,
|
||||
data_dir: &Path,
|
||||
project_host: &Arc<ide_host::Host>,
|
||||
) -> IdeResult<Arc<PluginHost>> {
|
||||
let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(deliver), Limits::default())?);
|
||||
let weak = Arc::downgrade(project_host);
|
||||
host.set_project_policy(Arc::new(move |root| {
|
||||
weak.upgrade().is_some_and(|host| host.trust.trusted(root).unwrap_or(false))
|
||||
}));
|
||||
let loader = host.clone();
|
||||
std::thread::Builder::new().name("plugin-load".into()).spawn(move || loader.load_all())?;
|
||||
Ok(host)
|
||||
|
||||
+88
-64
@@ -84,76 +84,100 @@ pub fn run() {
|
||||
// Paths macOS asked to open (Finder's Open With, the Dock) before setup ran: the first window takes them.
|
||||
let early_opens: Arc<Mutex<Option<Vec<PathBuf>>>> = Arc::default();
|
||||
let setup_opens = early_opens.clone();
|
||||
app.invoke_handler(builder.invoke_handler())
|
||||
.setup(move |app| {
|
||||
builder.mount_events(app);
|
||||
let state = AppState::new(app.handle().clone())?;
|
||||
let windows = WindowsState::default();
|
||||
if let Ok(cwd) = std::env::current_dir()
|
||||
&& let Some(path) = windows::cli_path(&std::env::args().collect::<Vec<_>>(), &cwd)
|
||||
{
|
||||
windows.set_launch("main", windows::path_launch(path));
|
||||
let invoke = Arc::new(builder.invoke_handler());
|
||||
app.invoke_handler(move |request| {
|
||||
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();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match connection.authorize_command(&label, &command).await {
|
||||
Ok(()) => {
|
||||
invoke(request);
|
||||
}
|
||||
Err(error) => request.resolver.reject(error),
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
let menu_state = menu::MenuState::default();
|
||||
menu::install(app.handle(), &menu_state, &state.recent.list());
|
||||
app.manage(state);
|
||||
app.manage(windows);
|
||||
app.manage(menu_state);
|
||||
if let Some(paths) = setup_opens.lock().take() {
|
||||
windows::open_paths_at_launch(app.handle(), paths);
|
||||
if let Err(error) = state.host.authorize_command(webview.label(), request.message.command()) {
|
||||
request.resolver.reject(error);
|
||||
return true;
|
||||
}
|
||||
app.on_menu_event(on_menu_event);
|
||||
windows::create_main_window(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::Focused(true) => {
|
||||
if let Some(w) = window.try_state::<WindowsState>() {
|
||||
w.focused(window.label());
|
||||
}
|
||||
}
|
||||
invoke(request)
|
||||
})
|
||||
.setup(move |app| {
|
||||
builder.mount_events(app);
|
||||
let state = AppState::new(app.handle().clone())?;
|
||||
let windows = WindowsState::default();
|
||||
if let Ok(cwd) = std::env::current_dir()
|
||||
&& let Some(path) = windows::cli_path(&std::env::args().collect::<Vec<_>>(), &cwd)
|
||||
{
|
||||
windows.set_launch("main", windows::path_launch(path));
|
||||
}
|
||||
let menu_state = menu::MenuState::default();
|
||||
menu::install(app.handle(), &menu_state, &state.recent.list());
|
||||
app.manage(state);
|
||||
app.manage(windows);
|
||||
app.manage(menu_state);
|
||||
if let Some(paths) = setup_opens.lock().take() {
|
||||
windows::open_paths_at_launch(app.handle(), paths);
|
||||
}
|
||||
app.on_menu_event(on_menu_event);
|
||||
windows::create_main_window(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::Focused(true) => {
|
||||
if let Some(w) = window.try_state::<WindowsState>() {
|
||||
w.focused(window.label());
|
||||
}
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
window_groups::destroyed(window.app_handle(), window.label());
|
||||
if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label()))
|
||||
{
|
||||
let _ = settings.destroy();
|
||||
}
|
||||
if let Some(state) = window.try_state::<AppState>() {
|
||||
state.close_project(window.label());
|
||||
}
|
||||
if let Some(w) = window.try_state::<WindowsState>() {
|
||||
w.forget(window.label());
|
||||
}
|
||||
if !windows::is_settings_window(window.label()) {
|
||||
windows::continue_quit(window.app_handle(), Some(window.label()));
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
window_groups::destroyed(window.app_handle(), window.label());
|
||||
if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) {
|
||||
let _ = settings.destroy();
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(context)
|
||||
.expect("error while running OpenIDEA")
|
||||
.run(move |app, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Opened { urls } = &event {
|
||||
let paths = windows::opened_paths(urls);
|
||||
if app.try_state::<WindowsState>().is_some() {
|
||||
windows::open_paths(app, paths);
|
||||
} else {
|
||||
early_opens.lock().get_or_insert_default().extend(paths);
|
||||
}
|
||||
if let Some(state) = window.try_state::<AppState>() {
|
||||
state.close_project(window.label());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = &early_opens;
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
state.host.shutdown_notebooks_on_exit();
|
||||
}
|
||||
// Cleanup must finish before update/restart replaces this process.
|
||||
if app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested()) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
if let Some(w) = window.try_state::<WindowsState>() {
|
||||
w.forget(window.label());
|
||||
}
|
||||
});
|
||||
if !windows::is_settings_window(window.label()) {
|
||||
windows::continue_quit(window.app_handle(), Some(window.label()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(context)
|
||||
.expect("error while running OpenIDEA")
|
||||
.run(move |app, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Opened { urls } = &event {
|
||||
let paths = windows::opened_paths(urls);
|
||||
if app.try_state::<WindowsState>().is_some() {
|
||||
windows::open_paths(app, paths);
|
||||
} else {
|
||||
early_opens.lock().get_or_insert_default().extend(paths);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = &early_opens;
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
state.host.shutdown_notebooks_on_exit();
|
||||
}
|
||||
// Cleanup must finish before update/restart replaces this process.
|
||||
if app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested()) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
|
||||
|
||||
@@ -53,6 +53,28 @@ type Channels = Arc<Mutex<HashMap<u64, (Channel<ChannelFrame>, u64)>>>;
|
||||
#[derive(Default)]
|
||||
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) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable("Project is in Restricted Mode"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Connections {
|
||||
pub fn get(&self, window: &str) -> Option<Arc<Connection>> {
|
||||
self.0.lock().get(window).cloned()
|
||||
|
||||
@@ -92,7 +92,7 @@ impl AppState {
|
||||
recent: RecentProjects::load(&config_dir),
|
||||
askpass: start_askpass(&app),
|
||||
sink: global_sink,
|
||||
plugins: crate::commands::plugin::create_host(&app, &data_dir)?,
|
||||
plugins: crate::commands::plugin::create_host(&app, &data_dir, &host)?,
|
||||
web: ide_web::WebServer::new(),
|
||||
remote_dev: crate::remote_dev::Connections::default(),
|
||||
host,
|
||||
|
||||
+20
-1
@@ -11,6 +11,10 @@ export const commands = {
|
||||
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
|
||||
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
|
||||
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("open_project", { path })),
|
||||
projectTrustStatus: (path: string) => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("project_trust_status", { path })),
|
||||
projectTrustList: () => typedError<TrustDecisions, IdeError>(__TAURI_INVOKE("project_trust_list")),
|
||||
projectTrustSet: (path: string, trusted: boolean, folder: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_set", { path, trusted, folder })),
|
||||
projectTrustRemoveLocation: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_remove_location", { path })),
|
||||
workspaceCreate: (path: string, name: string, projects: string[]) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_create", { path, name, projects })),
|
||||
workspaceAttach: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_attach", { path })),
|
||||
workspaceUpdate: (name: string, projects: string[]) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("workspace_update", { name, projects })),
|
||||
@@ -28,6 +32,7 @@ export const commands = {
|
||||
lightEditOpen: (path: string) => typedError<Settings_Serialize, IdeError>(__TAURI_INVOKE("light_edit_open", { path })),
|
||||
/** The project open in this window (a settings window: its parent's), if any. */
|
||||
projectCurrent: () => __TAURI_INVOKE<{
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
isGit: boolean,
|
||||
@@ -3463,7 +3468,7 @@ export type IdeError = {
|
||||
|
||||
export type IdeEvent =
|
||||
/** Project window membership or selection changed. */
|
||||
{ type: "windowGroupsChanged" } |
|
||||
{ type: "windowGroupsChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } |
|
||||
/** Files or directories changed on disk (after debouncing, ignore rules applied). */
|
||||
{ type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } |
|
||||
/** Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. */
|
||||
@@ -4770,6 +4775,7 @@ export type ProjectFileSet =
|
||||
export type ProjectInfo = ProjectInfo_Serialize | ProjectInfo_Deserialize;
|
||||
|
||||
export type ProjectInfo_Deserialize = {
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
isGit: boolean,
|
||||
@@ -4778,6 +4784,7 @@ export type ProjectInfo_Deserialize = {
|
||||
};
|
||||
|
||||
export type ProjectInfo_Serialize = {
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
isGit: boolean,
|
||||
@@ -6111,6 +6118,18 @@ export type TransferReport = {
|
||||
errors: TransferError[],
|
||||
};
|
||||
|
||||
export type TrustDecisions = {
|
||||
projects: string[],
|
||||
locations: string[],
|
||||
restricted: string[],
|
||||
};
|
||||
|
||||
export type TrustStatus = {
|
||||
root: IpcPath,
|
||||
trusted: boolean,
|
||||
decided: boolean,
|
||||
};
|
||||
|
||||
export type TypeScriptSettings = {
|
||||
/**
|
||||
* `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("EditorController", () => {
|
||||
it("carries the plain project's editor tabs into its automatic workspace", async () => {
|
||||
const settings = {};
|
||||
useProject.setState({
|
||||
project: { root: "/attach-a", name: "A", isGit: false, settings },
|
||||
project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings },
|
||||
settings,
|
||||
lightEdit: false,
|
||||
});
|
||||
@@ -185,6 +185,7 @@ describe("EditorController", () => {
|
||||
project: {
|
||||
root: "/attach-workspace",
|
||||
name: "A",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings,
|
||||
workspace: { version: 1, name: "A", projects: ["/attach-a", "/attach-b"] },
|
||||
@@ -230,7 +231,7 @@ describe("EditorController", () => {
|
||||
|
||||
it("builds IDEA's editor tab menu", async () => {
|
||||
useProject.setState({
|
||||
project: { name: "p", root: "/p", isGit: true, settings: {} } as never,
|
||||
project: { name: "p", root: "/p", trusted: true, isGit: true, settings: {} } as never,
|
||||
settings: {} as never,
|
||||
});
|
||||
const ids = [
|
||||
|
||||
@@ -82,6 +82,7 @@ it("shows the member and its directories in workspace breadcrumbs", async () =>
|
||||
root: "/workspace",
|
||||
name: "Workspace",
|
||||
settings: {},
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "Workspace", projects: ["/a", "/b"] },
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ function deps(files: string[], roots = ROOTS) {
|
||||
|
||||
beforeEach(() => {
|
||||
useProject.setState({
|
||||
project: { name: "p", root: "/p", isGit: false, settings: {} } as never,
|
||||
project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never,
|
||||
settings: {} as never,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,7 @@ describe("workspace task project", () => {
|
||||
const project = {
|
||||
root: "/workspace",
|
||||
name: "Workspace",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] },
|
||||
} as ProjectInfo;
|
||||
|
||||
@@ -44,7 +44,7 @@ export function activate(): () => void {
|
||||
const disposers = [
|
||||
activateSettingsPages(),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) sync(s.project?.root);
|
||||
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) sync(s.project?.root);
|
||||
}),
|
||||
registerToolWindow({
|
||||
id: TOOL_WINDOW,
|
||||
|
||||
@@ -165,7 +165,12 @@ export async function connect(): Promise<void> {
|
||||
};
|
||||
let tasks: AgentTask[], providers: AgentProvider[];
|
||||
try {
|
||||
[tasks, providers] = await Promise.all([call(commands.agentSubscribe(channel)), call(commands.agentProviders())]);
|
||||
[tasks, providers] = await Promise.all([
|
||||
call(commands.agentSubscribe(channel)),
|
||||
useProject.getState().project?.trusted === false
|
||||
? Promise.resolve([] as AgentProvider[])
|
||||
: call(commands.agentProviders()),
|
||||
]);
|
||||
} catch (e) {
|
||||
if (gen === generation) useAgents.setState({ loaded: true, error: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
|
||||
@@ -10,7 +10,7 @@ const windowTakeLaunch = vi.fn();
|
||||
const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} }));
|
||||
const fsReadDir = vi.fn(async (_path: string) => ({ status: "ok", data: [] }));
|
||||
const openProject = vi.fn(async (path: string) => {
|
||||
const project = { root: path, name: path, isGit: false, settings: {} };
|
||||
const project = { root: path, name: path, trusted: true, isGit: false, settings: {} };
|
||||
useProject.setState({ project, lightEdit: false } as never);
|
||||
return project;
|
||||
});
|
||||
@@ -61,7 +61,7 @@ provideEditorService({
|
||||
});
|
||||
|
||||
const ok = (data: unknown) => ({ status: "ok", data });
|
||||
const projectState = (root: string) => ({ project: { root, name: root, isGit: false, settings: {} } });
|
||||
const projectState = (root: string) => ({ project: { root, name: root, trusted: true, isGit: false, settings: {} } });
|
||||
|
||||
beforeEach(() => {
|
||||
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset();
|
||||
@@ -246,6 +246,7 @@ describe("Attach", () => {
|
||||
const before = {
|
||||
root: "/workspace",
|
||||
name: "A",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings: {},
|
||||
workspace: { version: 1, name: "A", projects: ["/p/a"] },
|
||||
@@ -269,6 +270,7 @@ it("removes the first workspace member while retaining the workspace state root"
|
||||
const project = {
|
||||
root: "/workspace",
|
||||
name: "A",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings: {},
|
||||
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { executeAction, type Action } from "@/platform/actions";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
basename,
|
||||
confirmLeaveProject,
|
||||
@@ -39,6 +39,7 @@ function desktopOnly(): boolean {
|
||||
}
|
||||
|
||||
function openFailed(path: string, e: unknown) {
|
||||
if (isCancelled(e)) return;
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: t("app.window.openFailed", { name: basename(path) }),
|
||||
|
||||
@@ -154,7 +154,7 @@ export function activate(): () => void {
|
||||
if (s.model !== prev.model || s.root !== prev.root) apply(s.root ? s.model : null, registered);
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root === prev.project?.root) return;
|
||||
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
|
||||
resetBuild();
|
||||
if (s.project) void loadModel();
|
||||
}),
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function loadModel(): Promise<void> {
|
||||
current: {},
|
||||
});
|
||||
}
|
||||
if (!root || !isTauri()) return;
|
||||
if (!root || useProject.getState().project?.trusted === false || !isTauri()) return;
|
||||
useBuild.setState({ loading: true });
|
||||
try {
|
||||
const [model, configs] = await Promise.all([
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("server status", () => {
|
||||
afterEach(() => dispose());
|
||||
|
||||
it("keeps a status that arrived after the snapshot was requested", async () => {
|
||||
useProject.setState({ project: { name: "p", root: "/p", isGit: false, settings: {} } as never });
|
||||
useProject.setState({ project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never });
|
||||
dispose = installServerEvents();
|
||||
const id = 1 as ServerId;
|
||||
const info: ServerInfo = { id, name: "jdtls", languages: [], root: "/p", status: "starting", pid: null };
|
||||
@@ -47,7 +47,7 @@ describe("server status", () => {
|
||||
});
|
||||
|
||||
it("drops a server the backend removed, even if an older snapshot still lists it", async () => {
|
||||
useProject.setState({ project: { name: "p", root: "/p", isGit: false, settings: {} } as never });
|
||||
useProject.setState({ project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never });
|
||||
dispose = installServerEvents();
|
||||
const info = (id: number, name: string): ServerInfo => ({
|
||||
id: id as ServerId,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* which applies them to every session (adapter defaults for filters without a choice).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { call, commands, isTauri, type ExceptionFilter } from "@/platform/ipc";
|
||||
|
||||
interface ExceptionState {
|
||||
@@ -128,7 +129,8 @@ export function mergeKnown(
|
||||
}
|
||||
|
||||
async function push() {
|
||||
if (!isTauri()) return;
|
||||
if (!isTauri() || !useExceptionBreakpoints.getState().root || useProject.getState().project?.trusted === false)
|
||||
return;
|
||||
try {
|
||||
await call(commands.debugSetExceptionBreakpoints(useExceptionBreakpoints.getState().choices));
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* them. Entries remember the source line they were created on for the gutter icon.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { call, commands, isTauri, type DataBreakpoint, type FunctionBreakpoint } from "@/platform/ipc";
|
||||
import { newBreakpointId, type BreakpointStatus } from "./model";
|
||||
import { useBreakpoints } from "./store";
|
||||
@@ -121,7 +122,7 @@ function scheduleSync() {
|
||||
}
|
||||
|
||||
export async function syncSpecial() {
|
||||
if (!isTauri()) return;
|
||||
if (!isTauri() || !useSpecialBreakpoints.getState().root || useProject.getState().project?.trusted === false) return;
|
||||
const { methods, fields } = useSpecialBreakpoints.getState();
|
||||
const muted = useBreakpoints.getState().muted;
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* The backend keeps them across sessions and returns verification states.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
makeBreakpoint,
|
||||
@@ -132,7 +133,7 @@ export function syncFile(path: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function sendFile(path: string) {
|
||||
if (!isTauri()) return;
|
||||
if (!isTauri() || useProject.getState().project?.trusted === false) return;
|
||||
const { byFile, muted } = useBreakpoints.getState();
|
||||
const list = byFile[path] ?? [];
|
||||
sent.set(
|
||||
|
||||
@@ -81,7 +81,7 @@ export function activate(): () => void {
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
const root = s.project?.root ?? null;
|
||||
if (root !== (prev.project?.root ?? null)) {
|
||||
if (root !== (prev.project?.root ?? null) || s.project?.trusted !== prev.project?.trusted) {
|
||||
resetSessions();
|
||||
loadBreakpoints(root);
|
||||
loadExceptionBreakpoints(root);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* a disposer. The editor provides the editor service; app must install its launch
|
||||
* barrier before welcome can restore a remembered project.
|
||||
*/
|
||||
import { activate as projectTrust } from "./project-trust";
|
||||
import { activate as editor } from "@/editor/core";
|
||||
import { activate as editorLsp } from "@/editor/lsp";
|
||||
import { activate as welcome } from "./welcome";
|
||||
@@ -46,6 +47,7 @@ import { activate as remoteDev } from "./remote-dev";
|
||||
type Activate = () => void | (() => void);
|
||||
|
||||
const features: Array<[string, Activate]> = [
|
||||
["project-trust", projectTrust],
|
||||
["editor", editor],
|
||||
["editor-lsp", editorLsp],
|
||||
["app", app],
|
||||
@@ -107,6 +109,7 @@ export { SettingsWindow, settingsWindowParent } from "./settings";
|
||||
*/
|
||||
export function activateSettingsWindowFeatures(parent: string): () => void {
|
||||
const pages: Array<[string, Activate]> = [
|
||||
["project-trust", projectTrust],
|
||||
["locale", appLocale],
|
||||
["settings", () => activateSettingsWindow(parent)],
|
||||
["templates", templatesPages],
|
||||
|
||||
@@ -90,6 +90,7 @@ const sdkKey = (s: ReturnType<typeof useProject.getState>) => {
|
||||
const l = s.settings?.languages;
|
||||
return JSON.stringify([
|
||||
s.project?.root ?? null,
|
||||
s.project?.trusted,
|
||||
l?.python?.interpreter,
|
||||
l?.java?.jdk,
|
||||
l?.node?.interpreter,
|
||||
@@ -116,7 +117,8 @@ export function activate(): () => void {
|
||||
};
|
||||
const refresh = () => {
|
||||
const id = ++seq;
|
||||
if (!useProject.getState().project || !isTauri()) return clear();
|
||||
if (!useProject.getState().project || useProject.getState().project?.trusted === false || !isTauri())
|
||||
return clear();
|
||||
call(commands.sdkProjectLibraries())
|
||||
.then((groups) => {
|
||||
if (id !== seq) return;
|
||||
|
||||
@@ -70,7 +70,7 @@ export function activate(): () => void {
|
||||
startPluginBridge(),
|
||||
// Dev plugins (`<project>/.openidea/plugins`) depend on the open project.
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) void refreshPlugins();
|
||||
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) void refreshPlugins();
|
||||
}),
|
||||
];
|
||||
void refreshPlugins();
|
||||
|
||||
@@ -53,7 +53,7 @@ let disposers: Array<() => void> = [];
|
||||
beforeEach(() => {
|
||||
useProjectTree.setState({ root: ROOT });
|
||||
useProject.setState({
|
||||
project: { name: "p", root: ROOT, isGit: true, settings: {} } as never,
|
||||
project: { name: "p", root: ROOT, trusted: true, isGit: true, settings: {} } as never,
|
||||
settings: {} as never,
|
||||
});
|
||||
disposers = [
|
||||
@@ -179,7 +179,7 @@ describe("project view context menu", () => {
|
||||
|
||||
it("offers Compare With… in a non-git project too", () => {
|
||||
useProject.setState({
|
||||
project: { name: "p", root: ROOT, isGit: false, settings: {} } as never,
|
||||
project: { name: "p", root: ROOT, trusted: true, isGit: false, settings: {} } as never,
|
||||
settings: {} as never,
|
||||
});
|
||||
const l = labels(contextMenuItems([node(`${ROOT}/a.py`)]));
|
||||
@@ -192,7 +192,7 @@ describe("project view context menu", () => {
|
||||
disposers.forEach((d) => d());
|
||||
disposers = [];
|
||||
useProject.setState({
|
||||
project: { name: "p", root: ROOT, isGit: true, settings: {} } as never,
|
||||
project: { name: "p", root: ROOT, trusted: true, isGit: true, settings: {} } as never,
|
||||
settings: {} as never,
|
||||
});
|
||||
const l = labels(contextMenuItems([node(`${ROOT}/a.py`)]));
|
||||
@@ -208,6 +208,7 @@ it("offers workspace removal for every member root", () => {
|
||||
root: "/workspace",
|
||||
name: "A",
|
||||
settings: {},
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ let disposers: Array<() => void> = [];
|
||||
beforeEach(() => {
|
||||
useProjectTree.setState({ root: ROOT });
|
||||
useProject.setState({
|
||||
project: { name: "p", root: ROOT, isGit: false, settings: {} } as never,
|
||||
project: { name: "p", root: ROOT, trusted: true, isGit: false, settings: {} } as never,
|
||||
settings: {} as never,
|
||||
});
|
||||
disposers = [
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("creating packages", () => {
|
||||
setProjectFsForTests(fs, "/p");
|
||||
initProjectTree("/p", "p");
|
||||
useProject.setState({
|
||||
project: { name: "p", root: "/p", isGit: false, settings: {} } as never,
|
||||
project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never,
|
||||
settings: {} as never,
|
||||
});
|
||||
useProjectRoots.setState({ root: "/p", detected: MAVEN });
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { call, commands, onIdeEvent, type TrustDecisions } from "@/platform/ipc";
|
||||
import { dirname, useProject } from "@/platform/project";
|
||||
import { initializeRemoteTrustMigration } from "@/platform/remote";
|
||||
import { provideProjectTrustPrompt } from "@/platform/projectTrust";
|
||||
import { registerOverlay, registerSettingsPage, registerWorkbenchBanner } from "@/workbench/contributions";
|
||||
import { Button, Checkbox, Dialog, T } from "@/ui";
|
||||
import styles from "./trust.module.css";
|
||||
|
||||
const usePrompt = create<{ path: string | null; folder: boolean; finish: ((open: boolean) => void) | null }>(() => ({
|
||||
path: null,
|
||||
folder: false,
|
||||
finish: null,
|
||||
}));
|
||||
|
||||
/** One line; a long path loses its middle, so the final folder stays visible. */
|
||||
function PathLine({ path }: { path: string }) {
|
||||
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
||||
return (
|
||||
<span className={styles.pathLine} title={path}>
|
||||
<span className={styles.pathHead}>{path.slice(0, Math.max(cut, 0))}</span>
|
||||
<span className={styles.pathTail}>{path.slice(Math.max(cut, 0))}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TrustDialog() {
|
||||
const { path, folder } = usePrompt();
|
||||
return (
|
||||
<Dialog
|
||||
open={!!path}
|
||||
icon="app"
|
||||
width={560}
|
||||
title="Trust and Open Project?"
|
||||
onClose={() => void executeAction("project.dontOpen")}
|
||||
buttons={[
|
||||
{
|
||||
label: "Preview in Restricted Mode",
|
||||
autoFocus: true,
|
||||
onClick: () => void executeAction("project.previewRestricted"),
|
||||
},
|
||||
{ label: "Don't Open", onClick: () => void executeAction("project.dontOpen") },
|
||||
{ label: "Trust Project", role: "default", onClick: () => void executeAction("project.trust") },
|
||||
]}
|
||||
footerStart={
|
||||
<Checkbox
|
||||
checked={folder}
|
||||
onChange={(folder) => usePrompt.setState({ folder })}
|
||||
className={styles.folderOption}
|
||||
label={
|
||||
<span className={styles.folderLabel}>
|
||||
<span className={styles.folderPrefix}>
|
||||
<T>Trust projects in</T>
|
||||
</span>{" "}
|
||||
<PathLine path={path ? dirname(path) : ""} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<PathLine path={path ?? ""} />
|
||||
<p>
|
||||
<T>
|
||||
If you don't trust the source, preview the project in Restricted Mode, where project commands and tools
|
||||
can't run.
|
||||
</T>
|
||||
</p>
|
||||
<p>
|
||||
<T>Loading, running or building a project may execute potentially malicious code from its build scripts.</T>
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RestrictedBanner() {
|
||||
const restricted = useProject((s) => s.project?.trusted === false);
|
||||
return restricted ? (
|
||||
<div className={styles.banner} role="status">
|
||||
<span>
|
||||
<T>Restricted Mode: project commands and tools are disabled.</T>
|
||||
</span>
|
||||
<Button onClick={() => void executeAction("project.trust")}>
|
||||
<T>Trust Project</T>
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function TrustSettings() {
|
||||
const [decisions, setDecisions] = useState<TrustDecisions | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const refresh = () =>
|
||||
call(commands.projectTrustList())
|
||||
.then(setDecisions)
|
||||
.catch((e: unknown) => setError(String(e)));
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
const revoke = async (action: string, path: string) => {
|
||||
try {
|
||||
await executeAction(action, path);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<p>
|
||||
<T>Trust decisions are saved in your user data and take effect immediately.</T>
|
||||
</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<h3>
|
||||
<T>Trusted projects</T>
|
||||
</h3>
|
||||
{decisions?.projects.map((path) => (
|
||||
<div className={styles.row} key={path}>
|
||||
<span className={styles.path}>{path}</span>
|
||||
<Button onClick={() => void revoke("project.revokeTrust", path)}>
|
||||
<T>Revoke Trust</T>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<h3>
|
||||
<T>Trusted locations</T>
|
||||
</h3>
|
||||
{decisions?.locations.map((path) => (
|
||||
<div className={styles.row} key={path}>
|
||||
<span className={styles.path}>{path}</span>
|
||||
<Button onClick={() => void revoke("project.removeTrustedLocation", path)}>
|
||||
<T>Remove</T>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function decide(trusted: boolean) {
|
||||
const pending = usePrompt.getState();
|
||||
const path = pending.path ?? useProject.getState().project?.root;
|
||||
if (!path) return;
|
||||
await call(commands.projectTrustSet(path, trusted, trusted && pending.folder));
|
||||
const project = await commands.projectCurrent();
|
||||
if (project) useProject.setState({ project, settings: project.settings });
|
||||
pending.finish?.(true);
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
initializeRemoteTrustMigration();
|
||||
const disposers = [
|
||||
provideProjectTrustPrompt(async (path) => {
|
||||
const status = await call(commands.projectTrustStatus(path));
|
||||
if (status.decided) return true;
|
||||
usePrompt.getState().finish?.(false);
|
||||
return new Promise<boolean>((resolve) =>
|
||||
usePrompt.setState({
|
||||
path: status.root,
|
||||
folder: false,
|
||||
finish: (open) => {
|
||||
usePrompt.setState({ path: null, finish: null, folder: false });
|
||||
resolve(open);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
registerOverlay({ id: "project.trustDialog", component: TrustDialog }),
|
||||
registerWorkbenchBanner({ id: "project.restrictedMode", component: RestrictedBanner }),
|
||||
registerSettingsPage({ id: "projectTrust", title: "Project Trust", group: "Security", component: TrustSettings }),
|
||||
registerActions([
|
||||
{ id: "project.trust", title: "Trust Project", category: "Project", run: () => decide(true) },
|
||||
{
|
||||
id: "project.previewRestricted",
|
||||
title: "Preview in Restricted Mode",
|
||||
category: "Project",
|
||||
hidden: true,
|
||||
run: () => decide(false),
|
||||
},
|
||||
{
|
||||
id: "project.dontOpen",
|
||||
title: "Don't Open",
|
||||
category: "Project",
|
||||
hidden: true,
|
||||
run: () => usePrompt.getState().finish?.(false),
|
||||
},
|
||||
{
|
||||
id: "project.revokeTrust",
|
||||
title: "Revoke Trust",
|
||||
category: "Project",
|
||||
run: async ({ arg }) => {
|
||||
const path = typeof arg === "string" ? arg : useProject.getState().project?.root;
|
||||
if (path) await call(commands.projectTrustSet(path, false, false));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "project.removeTrustedLocation",
|
||||
title: "Remove Trusted Location",
|
||||
category: "Project",
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
if (typeof arg === "string") await call(commands.projectTrustRemoveLocation(arg));
|
||||
},
|
||||
},
|
||||
]),
|
||||
onIdeEvent("projectTrustChanged", (e) => {
|
||||
const project = useProject.getState().project;
|
||||
if (project?.root === e.root) useProject.setState({ project: { ...project, trusted: e.trusted } });
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
usePrompt.getState().finish?.(false);
|
||||
disposers.reverse().forEach((dispose) => dispose());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.content p {
|
||||
margin: 0;
|
||||
}
|
||||
.pathLine {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.content > .pathLine {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.pathHead {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pathTail {
|
||||
flex: none;
|
||||
max-width: 70%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.folderOption {
|
||||
max-width: 100%;
|
||||
}
|
||||
.folderLabel {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.folderPrefix {
|
||||
flex: none;
|
||||
}
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.path {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { confirmProjectTrust } from "@/platform/projectTrust";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { emitLocalIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { OverlayHost } from "@/workbench/OverlayHost";
|
||||
import { useContributions } from "@/workbench/contributions";
|
||||
import { activate } from "./index";
|
||||
|
||||
const status = vi.fn();
|
||||
const decide = vi.fn();
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
projectTrustStatus: (path: string) => status(path),
|
||||
projectTrustSet: (...args: unknown[]) => decide(...args),
|
||||
projectCurrent: async () => null,
|
||||
},
|
||||
};
|
||||
});
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
status
|
||||
.mockReset()
|
||||
.mockImplementation(async (root: string) => ({ status: "ok", data: { root, trusted: false, decided: false } }));
|
||||
decide.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
useProject.setState({ project: null });
|
||||
dispose = activate();
|
||||
});
|
||||
afterEach(() => dispose());
|
||||
|
||||
it("offers three choices, cancels without a trust decision, and supports restricted preview", async () => {
|
||||
render(<OverlayHost />);
|
||||
const cancelled = confirmProjectTrust("/new").catch(() => "cancelled");
|
||||
await screen.findByRole("dialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Don't Open" }));
|
||||
expect(await cancelled).toBe("cancelled");
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
const preview = confirmProjectTrust("/new");
|
||||
await screen.findByRole("dialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Preview in Restricted Mode" }));
|
||||
await act(async () => preview);
|
||||
expect(decide).toHaveBeenCalledWith("/new", false, false);
|
||||
});
|
||||
|
||||
it("saves the parent-folder choice and reuses a persisted decision", async () => {
|
||||
render(<OverlayHost />);
|
||||
const opening = confirmProjectTrust("/projects/new");
|
||||
await screen.findByRole("dialog");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Trust projects in/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Trust Project" }));
|
||||
await act(async () => opening);
|
||||
expect(decide).toHaveBeenCalledWith("/projects/new", true, true);
|
||||
status.mockResolvedValue({ status: "ok", data: { trusted: true, decided: true } });
|
||||
await act(async () => confirmProjectTrust("/projects/other"));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the running project's banner after a backend trust transition", async () => {
|
||||
useProject.setState({ project: { root: "/p", name: "P", isGit: false, trusted: false, settings: {} } });
|
||||
const Banner = useContributions.getState().workbenchBanners.get("project.restrictedMode")!.component;
|
||||
render(<Banner />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Restricted Mode");
|
||||
await act(async () => executeAction("project.trust"));
|
||||
expect(decide).toHaveBeenCalledWith("/p", true, false);
|
||||
act(() => emitLocalIdeEvent({ type: "projectTrustChanged", root: "/p", trusted: true }));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows and trusts the canonical folder when opening through a symlink", async () => {
|
||||
status.mockResolvedValue({ status: "ok", data: { root: "/real/projects/new", trusted: false, decided: false } });
|
||||
render(<OverlayHost />);
|
||||
const opening = confirmProjectTrust("/aliases/new");
|
||||
await screen.findByRole("dialog");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Trust projects in \/real\/projects/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Trust Project" }));
|
||||
await act(async () => opening);
|
||||
expect(decide).toHaveBeenCalledWith("/real/projects/new", true, true);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useState } from "react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { call, commands, type RemoteEntry } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, type RemoteEntry } from "@/platform/ipc";
|
||||
import { useRemoteProgress } from "@/platform/remote";
|
||||
import { sshLabel } from "@/platform/remoteSettings";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
@@ -57,7 +57,7 @@ function ConnectDialog() {
|
||||
if (await openRemoteProject(ssh, target)) closeConnectDialog();
|
||||
else setBusy(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RemoteEntry } from "@/platform/ipc";
|
||||
import { forgetRemote, parseRemoteLocation, recentRemotes, rememberRemote, remoteLocation } from "@/platform/remote";
|
||||
import {
|
||||
initializeRemoteTrustMigration,
|
||||
takeLegacyRemoteTrust,
|
||||
forgetRemote,
|
||||
parseRemoteLocation,
|
||||
recentRemotes,
|
||||
rememberRemote,
|
||||
remoteLocation,
|
||||
} from "@/platform/remote";
|
||||
import { browsableFolders, listedDirectory, remoteParent } from "./model";
|
||||
|
||||
const entry = (path: string, isDir = true): RemoteEntry => ({
|
||||
@@ -57,3 +65,15 @@ describe("remote project locations", () => {
|
||||
expect(recentRemotes().map((r) => r.path)).toEqual(["/q"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates only the original remote recents once, scoped to the saved host", () => {
|
||||
localStorage.clear();
|
||||
rememberRemote({ sshConfig: "a", path: "/old", host: "me@old", name: "old", openedAtMs: 1 });
|
||||
initializeRemoteTrustMigration();
|
||||
rememberRemote({ sshConfig: "a", path: "/new", host: "me@old", name: "new", openedAtMs: 2 });
|
||||
initializeRemoteTrustMigration();
|
||||
expect(takeLegacyRemoteTrust("a", "/new", "me@old")).toBe(false);
|
||||
expect(takeLegacyRemoteTrust("a", "/old", "me@other")).toBe(false);
|
||||
expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(true);
|
||||
expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ const labels = (items: Item[]): string[] =>
|
||||
|
||||
describe("run context menu group", () => {
|
||||
beforeEach(() => {
|
||||
useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never });
|
||||
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
|
||||
useRun.setState({ configs: [npmTest], loaded: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -127,7 +127,7 @@ describe("menuGroupItems", () => {
|
||||
|
||||
describe("debug entry", () => {
|
||||
beforeEach(() => {
|
||||
useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never });
|
||||
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
|
||||
useRun.setState({ configs: [], loaded: true });
|
||||
});
|
||||
afterEach(() => useProject.setState({ project: null }));
|
||||
@@ -150,7 +150,7 @@ describe("run gutter popup", () => {
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never });
|
||||
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
|
||||
useRun.setState({ configs: [npmTest], loaded: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
@@ -516,7 +516,7 @@ export function activate(): () => void {
|
||||
);
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root === prev.project?.root) return;
|
||||
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
|
||||
resetRuns();
|
||||
useStackTraces.setState({ tabs: [], dialog: null });
|
||||
if (s.project) void loadConfigs();
|
||||
|
||||
@@ -164,6 +164,7 @@ function writeSelected(root: string, id: string | null) {
|
||||
}
|
||||
|
||||
export async function loadConfigs(): Promise<void> {
|
||||
if (useProject.getState().project?.trusted === false) return;
|
||||
const root = useProject.getState().project?.root;
|
||||
if (!root) {
|
||||
useRun.setState({ configs: [], loaded: false, selectedId: null });
|
||||
|
||||
@@ -49,7 +49,7 @@ let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useProject.setState({
|
||||
project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) },
|
||||
project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) } as never,
|
||||
});
|
||||
useRun.setState({ configs: [config], sessions: [], loaded: true });
|
||||
useDebug.setState({ sessions: {}, console: {}, active: null });
|
||||
|
||||
@@ -23,7 +23,7 @@ const config = (id: string, kind: string): RunConfig => ({
|
||||
debugAdapter: null,
|
||||
});
|
||||
function project(root: string) {
|
||||
useProject.setState({ project: { root, name: "app", isGit: false, settings: validateSettings({}) } });
|
||||
useProject.setState({ project: { root, name: "app", trusted: true, isGit: false, settings: validateSettings({}) } });
|
||||
}
|
||||
let dispose: Array<() => void>;
|
||||
const dockerRoots = [{ key: "docker:one", label: "Docker", icon: "file-docker" }];
|
||||
|
||||
@@ -67,13 +67,14 @@ async function hasPythonFiles(): Promise<boolean> {
|
||||
|
||||
function PythonStatus() {
|
||||
const root = useProject((s) => s.project?.root);
|
||||
const trusted = useProject((s) => s.project?.trusted !== false);
|
||||
const configured = useProject((s) => s.settings?.languages?.python?.interpreter ?? "");
|
||||
const status = usePopup((s) => s.status);
|
||||
const open = usePopup((s) => s.open);
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
reset();
|
||||
if (!root || !isTauri()) return;
|
||||
if (!root || !trusted || !isTauri()) return;
|
||||
let live = true;
|
||||
let sequence = 0;
|
||||
let detected: Promise<Sdk[]> | undefined;
|
||||
@@ -121,7 +122,7 @@ function PythonStatus() {
|
||||
off.forEach((dispose) => dispose());
|
||||
reset();
|
||||
};
|
||||
}, [root, configured]);
|
||||
}, [root, configured, trusted]);
|
||||
// The widget only renders once its project's status is in the store, so the popup action
|
||||
// sees the same state as the button without waiting for an effect to publish a flag.
|
||||
if (!popupShown() || !status) return null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Kbd,
|
||||
SearchField,
|
||||
Spinner,
|
||||
isPopupOpen,
|
||||
listNavigate,
|
||||
showContextMenu,
|
||||
T,
|
||||
@@ -263,7 +264,7 @@ export function ProjectsPage() {
|
||||
aria-label="Search projects"
|
||||
aria-controls={`${baseId}-list`}
|
||||
aria-activedescendant={items.length ? optionId(current) : undefined}
|
||||
autoFocus
|
||||
autoFocus={!isPopupOpen()}
|
||||
data-autofocus
|
||||
onKeyDown={onKeyDown}
|
||||
className={styles.search}
|
||||
|
||||
@@ -68,9 +68,9 @@ describe("session restore", () => {
|
||||
|
||||
it("remembers the open project and forgets it on explicit close", () => {
|
||||
const stop = trackSession();
|
||||
useProject.setState({ project: { root: "/w/a", name: "a", isGit: false, settings: {} } });
|
||||
useProject.setState({ project: { root: "/w/a", name: "a", trusted: true, isGit: false, settings: {} } });
|
||||
expect(lastSessionProject()).toBe("/w/a");
|
||||
useProject.setState({ project: { root: "/w/b", name: "b", isGit: false, settings: {} } });
|
||||
useProject.setState({ project: { root: "/w/b", name: "b", trusted: true, isGit: false, settings: {} } });
|
||||
expect(lastSessionProject()).toBe("/w/b");
|
||||
useProject.setState({ project: null });
|
||||
expect(lastSessionProject()).toBeNull();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Recent projects: loading, filtering, opening and removal. */
|
||||
import { create } from "zustand";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { commands, IpcError, isTauri, type RecentProject } from "@/platform/ipc";
|
||||
import { commands, IpcError, isCancelled, isTauri, type RecentProject } from "@/platform/ipc";
|
||||
import { openProject, useProject } from "@/platform/project";
|
||||
import { connectRemote, forgetRemote, parseRemoteLocation, recentRemotes, remoteLocation } from "@/platform/remote";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -113,6 +113,7 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
|
||||
await openProject(project.path);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) return false;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const missing = e instanceof IpcError ? e.kind === "notFound" : /not found|no such/i.test(message);
|
||||
notifications().notify({
|
||||
@@ -138,6 +139,7 @@ async function openRemote(
|
||||
await connectRemote(remote.sshConfig, remote.path);
|
||||
});
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) return false;
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx`Cannot open ${project.name}`,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { create } from "zustand";
|
||||
import { commands, isTauri, type Settings } from "@/platform/ipc";
|
||||
import { commands, isCancelled, isTauri, type Settings } from "@/platform/ipc";
|
||||
import { openProject, startupLaunchHandled, useProject } from "@/platform/project";
|
||||
import { connectRemote, parseRemoteLocation, projectLocation } from "@/platform/remote";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -123,6 +123,7 @@ async function reopen(path: string) {
|
||||
} catch (e) {
|
||||
write(SESSION_KEY, null);
|
||||
windowProject(null);
|
||||
if (isCancelled(e)) return;
|
||||
notifications().notify({
|
||||
level: "warning",
|
||||
title: `Cannot reopen ${name}`,
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
{
|
||||
"Project opening cancelled": "已取消打开项目",
|
||||
"Project is in Restricted Mode": "项目处于受限模式",
|
||||
"Project is in Restricted Mode. Trust the project to enable execution.": "项目处于受限模式。信任项目后可启用执行功能。",
|
||||
"Trust and Open Project?": "信任并打开项目?",
|
||||
"Preview in Restricted Mode": "以受限模式预览",
|
||||
"Don't Open": "不打开",
|
||||
"If you don't trust the source, preview the project in Restricted Mode, where project commands and tools can't run.": "如果不信任来源,请以受限模式预览项目,此模式下不会运行项目命令和工具。",
|
||||
"Loading, running or building a project may execute potentially malicious code from its build scripts.": "加载、运行或构建项目可能会执行其构建脚本中的恶意代码。",
|
||||
"Trust projects in": "信任此文件夹中的项目:",
|
||||
"Restricted Mode: project commands and tools are disabled.": "受限模式:项目命令和工具已禁用。",
|
||||
"Trust Project": "信任项目",
|
||||
"Trust decisions are saved in your user data and take effect immediately.": "信任决定保存在您的用户数据中,并立即生效。",
|
||||
"Trusted projects": "受信任的项目",
|
||||
"Trusted locations": "受信任的位置",
|
||||
"Revoke Trust": "撤销信任",
|
||||
"Project Trust": "项目信任",
|
||||
"Security": "安全",
|
||||
"Remove Trusted Location": "移除受信任的位置",
|
||||
"Cannot determine the module for main class '{value}'": "无法确定主类 '{value}' 所属的模块",
|
||||
"Attach": "附加",
|
||||
"Remove from Workspace": "从工作区移除",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** The project open in this window. */
|
||||
import { confirmProjectTrust } from "./projectTrust";
|
||||
import { create } from "zustand";
|
||||
import { call, commands, type ProjectInfo, type Settings } from "./ipc";
|
||||
import { isVirtualUri, virtualContainer, virtualName, virtualParent } from "./virtualDocs";
|
||||
@@ -48,6 +49,7 @@ export function startupLaunchHandled(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function openProject(path: string): Promise<ProjectInfo> {
|
||||
await confirmProjectTrust(path);
|
||||
useProject.setState({ opening: true });
|
||||
try {
|
||||
const project = await call(commands.openProject(path));
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("effective roots", () => {
|
||||
describe("project roots store", () => {
|
||||
beforeEach(() => {
|
||||
useProject.setState({
|
||||
project: { name: "p", root: "/p", isGit: false, settings: {} } as never,
|
||||
project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never,
|
||||
settings: { projectStructure: { excluded: ["out"] } } as never,
|
||||
});
|
||||
useProjectRoots.setState({ root: "/p", detected: DETECTED });
|
||||
@@ -122,6 +122,7 @@ it("lists workspace members as scopes and admits their files without admitting w
|
||||
root: "/workspace",
|
||||
name: "Workspace",
|
||||
settings: {},
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "Workspace", projects: ["/z", "/a"] },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IpcError } from "./ipc";
|
||||
|
||||
let prompt: ((path: string) => Promise<boolean>) | null = null;
|
||||
|
||||
export function provideProjectTrustPrompt(value: (path: string) => Promise<boolean>): () => void {
|
||||
prompt = value;
|
||||
return () => {
|
||||
if (prompt === value) prompt = null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmProjectTrust(path: string): Promise<void> {
|
||||
if (prompt && !(await prompt(path))) {
|
||||
throw new IpcError({ kind: "cancelled", message: "Project opening cancelled" });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { confirmProjectTrust } from "./projectTrust";
|
||||
/**
|
||||
* Remote development: in a window whose project lives on another machine, the
|
||||
* commands that machine's `openidea-server` answers are routed to it (through
|
||||
@@ -138,6 +139,19 @@ export async function connectRemote(sshConfig: string, path: string) {
|
||||
try {
|
||||
const r = await call(commands.remoteConnect(sshConfig, path, onProgress));
|
||||
enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands });
|
||||
if (takeLegacyRemoteTrust(r.sshConfig, path, r.host)) {
|
||||
const status = await call(commands.projectTrustStatus(r.root));
|
||||
if (!status.decided) await call(commands.projectTrustSet(r.root, true, false));
|
||||
}
|
||||
try {
|
||||
await confirmProjectTrust(r.root);
|
||||
} catch (error) {
|
||||
await commands.closeProject();
|
||||
disableRemote();
|
||||
throw error;
|
||||
}
|
||||
const current = await commands.projectCurrent();
|
||||
if (current) r.project = current;
|
||||
r.project.settings = await clientSettings(r.project.settings);
|
||||
useProject.setState({ project: r.project, settings: r.project.settings });
|
||||
rememberRemote({
|
||||
@@ -197,6 +211,32 @@ export interface RecentRemote {
|
||||
|
||||
const RECENT_KEY = "openidea.remote.recent";
|
||||
const RECENT_MAX = 20;
|
||||
const TRUST_MIGRATION_KEY = "openidea.remote.trustMigration";
|
||||
|
||||
/** Snapshot existing user-owned recents before any new connection is remembered. */
|
||||
export function initializeRemoteTrustMigration() {
|
||||
try {
|
||||
if (localStorage.getItem(TRUST_MIGRATION_KEY) === null) {
|
||||
localStorage.setItem(TRUST_MIGRATION_KEY, JSON.stringify(recentRemotes()));
|
||||
}
|
||||
} catch {
|
||||
/* Unavailable storage never grants trust. */
|
||||
}
|
||||
}
|
||||
|
||||
export function takeLegacyRemoteTrust(sshConfig: string, path: string, host: string): boolean {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(TRUST_MIGRATION_KEY) ?? "[]");
|
||||
if (!Array.isArray(value)) return false;
|
||||
const matches = (entry: RecentRemote) =>
|
||||
entry?.sshConfig === sshConfig && entry.path === path && entry.host === host;
|
||||
const found = value.some(matches);
|
||||
localStorage.setItem(TRUST_MIGRATION_KEY, JSON.stringify(value.filter((entry) => !matches(entry))));
|
||||
return found;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function recentRemotes(): RecentRemote[] {
|
||||
try {
|
||||
|
||||
@@ -80,7 +80,9 @@ describe("remote client settings", () => {
|
||||
commands: [...served],
|
||||
project: { root: "/remote", name: "remote", settings: remote },
|
||||
}
|
||||
: local,
|
||||
: cmd === "remote_invoke"
|
||||
? "null"
|
||||
: local,
|
||||
);
|
||||
await connectRemote("test", "/remote");
|
||||
expect(useProject.getState().settings).toMatchObject({ keymap: "my-map", editor: { tabSize: 2, fontSize: 19 } });
|
||||
|
||||
@@ -15,6 +15,9 @@ html {
|
||||
--fw-code-bold: 700;
|
||||
}
|
||||
:root {
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
/* surfaces */
|
||||
--bg-app: #191a1c;
|
||||
--bg-elev: #212326;
|
||||
|
||||
@@ -231,6 +231,11 @@
|
||||
.messageIcon {
|
||||
flex: none;
|
||||
}
|
||||
.messageAppIcon {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.messageShape {
|
||||
fill: var(--message-icon-color);
|
||||
}
|
||||
|
||||
+7
-2
@@ -7,6 +7,7 @@ import { Icon } from "./icons";
|
||||
import { FOCUSABLE, onWindowDeactivate, openLayer, trapTab } from "./popupState";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { selectableText } from "./selectableText";
|
||||
import appIcon from "../../src-tauri/icons/128x128.png";
|
||||
import styles from "./Dialog.module.css";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
|
||||
@@ -135,7 +136,7 @@ export interface DialogProps {
|
||||
* A Messages alert (IDEA `MessageDialogBuilder`): no title bar, this icon on the
|
||||
* left, the title in bold above the content, `footerStart` under the text.
|
||||
*/
|
||||
icon?: MessageIconKind;
|
||||
icon?: MessageIconKind | "app";
|
||||
}
|
||||
|
||||
export type MessageIconKind = "question" | "info" | "warning" | "error";
|
||||
@@ -398,7 +399,11 @@ function DialogInner({
|
||||
)}
|
||||
{icon ? (
|
||||
<div className={styles.message} data-dialog-body="" onPointerDown={drag}>
|
||||
<MessageIcon kind={icon} />
|
||||
{icon === "app" ? (
|
||||
<img className={styles.messageAppIcon} src={appIcon} alt="" draggable={false} />
|
||||
) : (
|
||||
<MessageIcon kind={icon} />
|
||||
)}
|
||||
<div className={styles.messageText}>
|
||||
{title != null && <div className={clsx(styles.messageTitle, selectableText)}>{title}</div>}
|
||||
<div className={clsx(styles.messageBody, selectableText)}>
|
||||
|
||||
@@ -328,7 +328,11 @@ describe("workbench shell", () => {
|
||||
useProject.setState({ project: null });
|
||||
const { unmount } = render(<Workbench />);
|
||||
expect(screen.getByRole("button", { name: /Open/ })).toBeInTheDocument();
|
||||
act(() => useProject.setState({ project: { root: "/shell", name: "shell-demo", isGit: false, settings: {} } }));
|
||||
act(() =>
|
||||
useProject.setState({
|
||||
project: { root: "/shell", name: "shell-demo", trusted: true, isGit: false, settings: {} },
|
||||
}),
|
||||
);
|
||||
loadToolWindowLayout("/shell");
|
||||
expect(await screen.findByText("shell-demo")).toBeInTheDocument();
|
||||
expect(screen.getByText("project view")).toBeInTheDocument();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useApplyTheme } from "./theme";
|
||||
import { ToolWindowLayout } from "./toolwindows/ToolWindows";
|
||||
import { Welcome } from "./Welcome";
|
||||
import { WindowResizeHandles } from "./WindowResizeHandles";
|
||||
import { useContributions } from "./contributions";
|
||||
import styles from "./Workbench.module.css";
|
||||
|
||||
/** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */
|
||||
@@ -38,6 +39,7 @@ function LightEditFrame() {
|
||||
|
||||
export function Workbench() {
|
||||
useApplyTheme();
|
||||
const banners = useContributions((s) => s.workbenchBanners);
|
||||
const project = useProject((s) => s.project);
|
||||
const lightEdit = useProject((s) => s.lightEdit);
|
||||
const root = project?.root ?? null;
|
||||
@@ -54,6 +56,9 @@ export function Workbench() {
|
||||
{/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */}
|
||||
<TitleBar bare={distractionFree} />
|
||||
{!distractionFree && <WindowGroupTabs />}
|
||||
{[...banners.values()].map(({ id, component: Banner }) => (
|
||||
<Banner key={id} />
|
||||
))}
|
||||
<ToolWindowLayout key={root} editor={<EditorArea />} />
|
||||
{!distractionFree && <StatusBar />}
|
||||
</>
|
||||
|
||||
@@ -264,7 +264,13 @@ export interface EditorInput {
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkbenchBannerContribution {
|
||||
id: string;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
interface Contributions {
|
||||
workbenchBanners: Map<string, WorkbenchBannerContribution>;
|
||||
toolWindows: Map<string, ToolWindowContribution>;
|
||||
statusItems: Map<string, StatusItemContribution>;
|
||||
overlays: Map<string, OverlayContribution>;
|
||||
@@ -274,6 +280,7 @@ interface Contributions {
|
||||
}
|
||||
|
||||
export const useContributions = create<Contributions>(() => ({
|
||||
workbenchBanners: new Map(),
|
||||
toolWindows: new Map(),
|
||||
statusItems: new Map(),
|
||||
overlays: new Map(),
|
||||
@@ -301,6 +308,7 @@ function register<K extends keyof Contributions>(
|
||||
});
|
||||
}
|
||||
|
||||
export const registerWorkbenchBanner = (c: WorkbenchBannerContribution) => register("workbenchBanners", c.id, c);
|
||||
export const registerToolWindow = (c: ToolWindowContribution) => register("toolWindows", c.id, c);
|
||||
export const registerStatusItem = (c: StatusItemContribution) => register("statusItems", c.id, c);
|
||||
export const registerOverlay = (c: OverlayContribution) => register("overlays", c.id, c);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user