mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-12 18:36:53 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d6ef9964 | ||
|
|
d6c562049f | ||
|
|
100075166c | ||
|
|
d5332a2197 | ||
|
|
b0d20d7997 | ||
|
|
8df59cf5a7 | ||
|
|
3c68d2ea7f | ||
|
|
cfe1416dbd | ||
|
|
44fc0a2ebc |
No files matched your search
@@ -143,9 +143,12 @@ mod tests {
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let (count, on_change) = counting();
|
||||
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
|
||||
// FSEvents can deliver the creation of `.openidea/` above after the watch started.
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
let seen = count.load(Ordering::SeqCst);
|
||||
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
|
||||
std::fs::write(root.join("main.py"), "").unwrap();
|
||||
std::thread::sleep(SETTLE * 3);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(count.load(Ordering::SeqCst), seen);
|
||||
}
|
||||
}
|
||||
@@ -114,14 +114,28 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
if empty && !bom.is_empty() {
|
||||
std::fs::write(&path, bom)?;
|
||||
}
|
||||
// A checkout backend opens the file for add; Git's hook is a no-op.
|
||||
if let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path))) {
|
||||
tracing::debug!("vcs files_added for {}: {e}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => ide_vfs::create_file(&path),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
|
||||
pub async fn create_file_bytes(host: &Host, window: &str, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_vfs::create_file_bytes(&path, &data)?;
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path)))
|
||||
{
|
||||
tracing::debug!("vcs files_added for {}: {e}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
@@ -135,25 +149,54 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
|
||||
|
||||
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
|
||||
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
let history = host.project(window).ok().map(|p| p.history.clone());
|
||||
let project = host.project(window).ok();
|
||||
let history = project.as_ref().map(|p| p.history.clone());
|
||||
host.blocking(move || {
|
||||
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
|
||||
if let Some(h) = history {
|
||||
for p in paths {
|
||||
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
|
||||
for p in &paths {
|
||||
h.record_deletion(p, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
}
|
||||
ide_workspace::file_ops::delete(&path)
|
||||
ide_workspace::file_ops::delete(&path)?;
|
||||
// A checkout backend opens the paths for delete; Git's hook is a no-op.
|
||||
if let Some(p) = project {
|
||||
let _ = p.with_backend_of(&path, |r| r.files_deleted(&paths));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
|
||||
pub async fn copy(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_workspace::file_ops::copy(&from, &to)?;
|
||||
// A copy is an add at the destination; Git's hook is a no-op.
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&to, |r| r.files_added(std::slice::from_ref(&to)))
|
||||
{
|
||||
tracing::debug!("vcs files_added for {}: {e}", to.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
|
||||
pub async fn rename(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_workspace::file_ops::rename(&from, &to)?;
|
||||
// A checkout backend records the rename so history follows the file;
|
||||
// Git's hook is a no-op.
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&to, |r| r.file_moved(&from, &to))
|
||||
{
|
||||
tracing::debug!("vcs file_moved {} → {}: {e}", from.display(), to.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
|
||||
@@ -48,7 +48,7 @@ async fn resolve_launch(
|
||||
let build = policy != BuildPolicy::NoBuild;
|
||||
let full = policy == BuildPolicy::Rebuild;
|
||||
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let (model, external, delegated, prefer_maven) = {
|
||||
let (model, external, mut delegated, prefer_maven, is_maven) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
let module = module.clone();
|
||||
@@ -68,13 +68,29 @@ async fn resolve_launch(
|
||||
prefer_maven,
|
||||
);
|
||||
let external = non_java || delegated;
|
||||
(model, external, delegated, prefer_maven)
|
||||
(model, external, delegated, prefer_maven, is_maven)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut external_build =
|
||||
if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
|
||||
if info.errors.iter().any(|e| processor_recreated_a_file(&e.message)) {
|
||||
if build && !delegated && is_maven {
|
||||
// JDT runs an annotation processor again over sources it generated in the same build
|
||||
// (MapStruct over MapStruct-Plus's mappers) and fails on the file it already wrote;
|
||||
// javac doesn't, so Maven builds the module.
|
||||
tracing::info!("Annotation processing failed in JDT; building with Maven");
|
||||
delegated = true;
|
||||
external_build = Some(project.lsp.java_external_build(&module, cancel).await?);
|
||||
info = project.lsp.java_launch(&module, test, false, false, true, cancel).await?;
|
||||
}
|
||||
if delegated {
|
||||
// Maven runs the annotation processors itself; JDT's failure to rerun one is no compile error.
|
||||
drop_processor_recreate_errors(&mut info);
|
||||
}
|
||||
}
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
@@ -338,10 +354,60 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
});
|
||||
}
|
||||
|
||||
/// An annotation processor's error for writing a file this build already wrote (JDT's
|
||||
/// `IdeFilerImpl`: "Source file already created", javac's Filer: "Attempt to recreate a file").
|
||||
fn processor_recreated_a_file(message: &str) -> bool {
|
||||
message.contains("FilerException")
|
||||
&& (message.contains("Source file already created") || message.contains("Attempt to recreate a file"))
|
||||
}
|
||||
|
||||
fn drop_processor_recreate_errors(info: &mut JavaLaunchInfo) {
|
||||
let before = info.errors.len();
|
||||
info.errors.retain(|e| !processor_recreated_a_file(&e.message));
|
||||
info.error_count = info.error_count.saturating_sub((before - info.errors.len()) as u32);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognises_a_processor_recreating_its_output() {
|
||||
assert!(processor_recreated_a_file(
|
||||
"Internal error in the mapping processor: java.lang.RuntimeException: \
|
||||
javax.annotation.processing.FilerException: Source file already created: /m/target/generated-sources/annotations/a/AMapper__4Impl.java"
|
||||
));
|
||||
assert!(processor_recreated_a_file(
|
||||
"javax.annotation.processing.FilerException: Attempt to recreate a file for type a.B"
|
||||
));
|
||||
assert!(!processor_recreated_a_file("Source file already created"));
|
||||
assert!(!processor_recreated_a_file("cannot find symbol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delegated_launch_ignores_jdt_processor_recreate_errors() {
|
||||
let error = |message: &str| ide_lsp::jdtls::JavaCompileError {
|
||||
path: "src/main/java/a/A.java".into(),
|
||||
line: 1,
|
||||
column: 1,
|
||||
message: message.into(),
|
||||
};
|
||||
let mut info = JavaLaunchInfo {
|
||||
error_count: 2,
|
||||
errors: vec![
|
||||
error(
|
||||
"javax.annotation.processing.FilerException: Source file already created: /m/AMapper__1Impl.java",
|
||||
),
|
||||
error("cannot find symbol"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
drop_processor_recreate_errors(&mut info);
|
||||
assert_eq!(info.error_count, 1);
|
||||
assert_eq!(info.errors.len(), 1);
|
||||
assert_eq!(info.errors[0].message, "cannot find symbol");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
|
||||
|
||||
@@ -138,6 +138,11 @@ impl LogAccess {
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
.with_log_repo_at(at, f)
|
||||
}
|
||||
|
||||
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
|
||||
fn with_log_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
}
|
||||
|
||||
/// A repository of the project (IDEA's VCS root).
|
||||
@@ -280,6 +285,16 @@ fn per_repo(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `per_repo` through the backend-agnostic Changes-view surface.
|
||||
fn per_backend(
|
||||
project: &Project,
|
||||
paths: &[PathBuf],
|
||||
recorded: Option<&str>,
|
||||
f: impl Fn(&dyn Backend, &[PathBuf]) -> IdeResult<()>,
|
||||
) -> IdeResult<()> {
|
||||
per_repo(project, paths, recorded, |r, paths| f(r as &dyn Backend, paths))
|
||||
}
|
||||
|
||||
/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files.
|
||||
fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult<PathBuf> {
|
||||
let repos = project.repos();
|
||||
@@ -348,7 +363,7 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
project.with_backend_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -360,7 +375,7 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
|
||||
});
|
||||
blocking(move || {
|
||||
if let Some(project) = project {
|
||||
return project.with_log_repo_of(&path, |r| {
|
||||
return project.with_log_backend_of(&path, |r| {
|
||||
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
|
||||
});
|
||||
}
|
||||
@@ -383,7 +398,7 @@ pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option
|
||||
});
|
||||
blocking(move || {
|
||||
if let Some(project) = project {
|
||||
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
|
||||
return project.with_log_backend_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
|
||||
}
|
||||
external
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
@@ -403,7 +418,7 @@ pub async fn vcs_blame(
|
||||
// An explicit text wins (merge view annotates its side panes, not the worktree).
|
||||
blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_repo_of(&path, |r| {
|
||||
project.with_backend_of(&path, |r| {
|
||||
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
|
||||
})
|
||||
})
|
||||
@@ -424,7 +439,7 @@ pub async fn vcs_block_history(
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_repo_of(&path, |r| {
|
||||
project.with_backend_of(&path, |r| {
|
||||
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
|
||||
})
|
||||
})
|
||||
@@ -441,7 +456,7 @@ pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: Lo
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
r.enable_log_index(&project.cache);
|
||||
r.log_cancellable(&query, &cancel)
|
||||
(r as &dyn Backend).log(&query, &cancel)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
@@ -540,7 +555,7 @@ pub async fn vcs_commit_files(
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
}
|
||||
|
||||
/// Combined changes of several commits, `commits` oldest first.
|
||||
@@ -551,7 +566,7 @@ pub async fn vcs_commits_changes(
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
@@ -567,7 +582,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
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
/// Project-host operation (also registered with the remote dispatcher).
|
||||
@@ -602,7 +617,7 @@ 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
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
@@ -614,7 +629,7 @@ pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeRes
|
||||
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
|
||||
blocking(move || per_backend(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit(
|
||||
@@ -628,8 +643,8 @@ pub async fn vcs_commit(
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), None, |r| {
|
||||
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
project.with_backend_recorded(repo.as_deref(), None, |r| {
|
||||
r.commit(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -886,7 +901,7 @@ pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) ->
|
||||
|
||||
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
|
||||
blocking(move || project.with_backend_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1201,7 +1216,7 @@ pub async fn vcs_diff_with_worktree(
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (the "N files updated" of Update Project).
|
||||
@@ -1213,7 +1228,7 @@ pub async fn vcs_diff_revisions(
|
||||
to: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
|
||||
|
||||
@@ -1276,12 +1276,13 @@ pub(super) fn commit_draft(
|
||||
};
|
||||
// Both callers have checked that the window's project is trusted.
|
||||
r.set_execution_allowed(true);
|
||||
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|
||||
|e| match e.kind {
|
||||
let backend = &r as &dyn ide_vcs::Backend;
|
||||
let pending = backend
|
||||
.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path)))
|
||||
.map_err(|e| match e.kind {
|
||||
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
},
|
||||
)?;
|
||||
})?;
|
||||
let root = to_ipc(r.root());
|
||||
for c in pending {
|
||||
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
|
||||
|
||||
@@ -47,6 +47,48 @@ pub const SECRET_REQUEST: &str = "plugin_secret";
|
||||
pub const SECRET_WRITE: &str = "plugin_secret_write";
|
||||
/// OAuth listeners bound on the desktop, never the project server.
|
||||
pub const AUTH_REQUEST: &str = "plugin_auth";
|
||||
/// What [`RemotePlugins::register`] adds to a server's commands.
|
||||
pub const COMMANDS: &[&str] = &[
|
||||
SYNC_PACKAGES,
|
||||
"plugin_run_command",
|
||||
"plugin_set_ui_locale",
|
||||
"plugin_select_tests",
|
||||
"plugin_parse_test_results",
|
||||
"plugin_terminal_session_close",
|
||||
"plugin_terminal_session_spawn",
|
||||
"plugin_prepare_debug_run",
|
||||
"plugin_finish_operation",
|
||||
"plugin_content_tab_action",
|
||||
"plugin_post_events",
|
||||
"plugin_event_kinds",
|
||||
"plugin_status_items",
|
||||
"plugin_context_keys",
|
||||
"plugin_cancel_task",
|
||||
"plugin_answer_prompt",
|
||||
"plugin_choose_program",
|
||||
"plugin_program",
|
||||
"plugin_answer_command",
|
||||
"plugin_answer_commit",
|
||||
"plugin_commit_checks",
|
||||
"mcp_set_open_editors",
|
||||
"mcp_set_focused_editor",
|
||||
"mcp_report_problems",
|
||||
"plugin_decoration_sets",
|
||||
"plugin_set_active_file",
|
||||
"plugin_view_render",
|
||||
"plugin_view_event",
|
||||
"plugin_service_detail",
|
||||
"plugin_service_log",
|
||||
"plugin_navigation_files",
|
||||
"plugin_navigation_invalidate",
|
||||
"plugin_navigation_index",
|
||||
"plugin_navigation_goto",
|
||||
"plugin_navigation_markers",
|
||||
"plugin_endpoints",
|
||||
"plugin_run_targets",
|
||||
"plugin_answer_run_target",
|
||||
"plugin_line_markers",
|
||||
];
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SecretWrite {
|
||||
@@ -134,8 +176,13 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
|
||||
}
|
||||
|
||||
/// Whether a plugin event means something to a remote window (the desktop
|
||||
/// owns the plugin list, themes and consent).
|
||||
/// owns the plugin list, themes and consent). The desktop checks it again on
|
||||
/// arrival: a server may send anything, and only what acts on the remote project
|
||||
/// passes. A command may only be another plugin command, which runs on the server.
|
||||
pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
if let PluginEvent::ExecuteCommand { id, .. } = event {
|
||||
return plugin_command(id);
|
||||
}
|
||||
matches!(
|
||||
event,
|
||||
PluginEvent::RunTargetsChanged
|
||||
@@ -147,7 +194,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::Notification { .. }
|
||||
| PluginEvent::StatusItemChanged { .. }
|
||||
| PluginEvent::WindowStatusItemChanged { .. }
|
||||
| PluginEvent::ExecuteCommand { .. }
|
||||
| PluginEvent::OutputChanged { .. }
|
||||
| PluginEvent::RevealOutput { .. }
|
||||
| PluginEvent::ContextChanged { .. }
|
||||
@@ -163,7 +209,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenLogTab { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
| PluginEvent::OpenProject { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::PromptWaiting { .. }
|
||||
@@ -173,6 +218,13 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `plugin.<plugin id>.<command id>`, as the workbench names plugin commands.
|
||||
fn plugin_command(id: &str) -> bool {
|
||||
id.strip_prefix("plugin.")
|
||||
.and_then(|rest| rest.split_once('.'))
|
||||
.is_some_and(|(plugin, command)| !plugin.is_empty() && !command.is_empty() && !command.ends_with('.'))
|
||||
}
|
||||
|
||||
// ---- desktop side -----------------------------------------------------------
|
||||
|
||||
impl PluginHost {
|
||||
@@ -951,4 +1003,43 @@ mod tests {
|
||||
assert!(valid_id("ai-completion"));
|
||||
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
|
||||
}
|
||||
|
||||
fn execute(id: &str) -> PluginEvent {
|
||||
PluginEvent::ExecuteCommand {
|
||||
request_id: "1".into(),
|
||||
id: id.into(),
|
||||
arg: Some(r"\\host.example\share\pkg".into()),
|
||||
chain: vec!["tools".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_runs_only_plugin_commands_in_the_desktop_window() {
|
||||
assert!(forwarded(&execute("plugin.tools.build")));
|
||||
assert!(forwarded(&execute("plugin.acme.tools.build.all")));
|
||||
for id in [
|
||||
"plugins.installFromDisk",
|
||||
"project.revokeTrust",
|
||||
"project.removeTrustedLocation",
|
||||
"ssh.startSession",
|
||||
"file.openProjectPath",
|
||||
"terminal.runCommand",
|
||||
"plugin.",
|
||||
"plugin.tools",
|
||||
"plugin..build",
|
||||
"plugin.tools.",
|
||||
] {
|
||||
assert!(!forwarded(&execute(id)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_does_not_open_desktop_folders() {
|
||||
let open = PluginEvent::OpenProject {
|
||||
plugin_id: "tools".into(),
|
||||
path: r"\\host.example\share\project".into(),
|
||||
new_window: true,
|
||||
};
|
||||
assert!(!forwarded(&open));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
|
||||
use ide_lsp::LspManager;
|
||||
use ide_syntax::StructureService;
|
||||
use ide_text::{AppliedChange, ByteEdit};
|
||||
use ide_vcs::{Repo, Repos};
|
||||
use ide_vcs::{Backend, Repo, Repos};
|
||||
use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
@@ -971,6 +971,11 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
/// `with_repo_of` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_of(path, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
if self.is_trusted() {
|
||||
@@ -980,6 +985,40 @@ impl Project {
|
||||
f(repo)
|
||||
}
|
||||
|
||||
/// `with_repo_at` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_log_backend_at<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_log_repo_of` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_log_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_log_repo_of(path, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_repo` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend<T>(&self, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_backend_at(None, f)
|
||||
}
|
||||
|
||||
/// `with_repo_recorded` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_recorded<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
operation: Option<&str>,
|
||||
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
self.with_repo_recorded(at, operation, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
|
||||
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(None, f)
|
||||
@@ -1004,7 +1043,7 @@ impl Project {
|
||||
) -> IdeResult<T> {
|
||||
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
|
||||
self.history_label(&format!("Before {name}"));
|
||||
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
|
||||
if let Ok(changed) = self.with_backend_at(at, |r| r.status()) {
|
||||
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
|
||||
+106
-3
@@ -185,15 +185,15 @@ impl Dispatcher {
|
||||
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
ops::fs::create_file_bytes(&c.host, &c.window, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
|
||||
})
|
||||
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_copy_merge", |c| async move {
|
||||
ops::fs::copy_merge(
|
||||
&c.host,
|
||||
@@ -298,6 +298,56 @@ pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core
|
||||
Arc::new(PeerSink { peer, window })
|
||||
}
|
||||
|
||||
/// Whether a desktop shows an `event` notification of its server in the window. A server reports
|
||||
/// on its project; what this machine answers or owns itself (MCP approvals, app updates, window
|
||||
/// groups) never comes from it.
|
||||
pub fn client_accepts(event: &IdeEvent) -> bool {
|
||||
match event {
|
||||
IdeEvent::McpRequest { .. }
|
||||
| IdeEvent::McpRequestClosed { .. }
|
||||
| IdeEvent::WindowGroupsChanged
|
||||
| IdeEvent::WindowFullscreenChanged
|
||||
| IdeEvent::UpdateStatusChanged { .. }
|
||||
| IdeEvent::UpdateAvailable { .. } => false,
|
||||
IdeEvent::DbSourcesChanged
|
||||
| IdeEvent::ProjectTrustChanged { .. }
|
||||
| IdeEvent::FsChanged { .. }
|
||||
| IdeEvent::ExcludedDirsChanged { .. }
|
||||
| IdeEvent::DocPatched { .. }
|
||||
| IdeEvent::DocDirty { .. }
|
||||
| IdeEvent::WorkspaceEditApplied { .. }
|
||||
| IdeEvent::DocLanguage { .. }
|
||||
| IdeEvent::Diagnostics { .. }
|
||||
| IdeEvent::Task { .. }
|
||||
| IdeEvent::IndexState { .. }
|
||||
| IdeEvent::VcsChanged { .. }
|
||||
| IdeEvent::LanguageServer { .. }
|
||||
| IdeEvent::LanguageServerWarning { .. }
|
||||
| IdeEvent::LanguageServerRemoved { .. }
|
||||
| IdeEvent::LspRefresh { .. }
|
||||
| IdeEvent::TerminalExited { .. }
|
||||
| IdeEvent::RunExited { .. }
|
||||
| IdeEvent::Debug { .. }
|
||||
| IdeEvent::Notify { .. }
|
||||
| IdeEvent::SettingsChanged { .. }
|
||||
| IdeEvent::ProjectRootsChanged { .. }
|
||||
| IdeEvent::Askpass { .. }
|
||||
| IdeEvent::AskpassDone { .. }
|
||||
| IdeEvent::NotebookKernel { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands a desktop routes to its server: what `openidea-server` of this build answers,
|
||||
/// fixed here rather than taken from the server, so a server cannot move a command to this machine.
|
||||
pub fn client_commands() -> Vec<&'static str> {
|
||||
let mut out = Dispatcher::standard().commands();
|
||||
out.extend(crate::plugins::remote_host::COMMANDS);
|
||||
out.push(crate::plugins::remote::SYNC_COMMAND);
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
struct PeerSink {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
window: Option<String>,
|
||||
@@ -364,3 +414,56 @@ pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::event::{AskpassKind, McpUiRequest, NotifyLevel};
|
||||
|
||||
#[test]
|
||||
fn a_server_cannot_raise_desktop_prompts() {
|
||||
let confirm = McpUiRequest::Confirm {
|
||||
client: "agent".into(),
|
||||
tool: "run".into(),
|
||||
subject: "rm -rf".into(),
|
||||
project: "/srv/app".into(),
|
||||
detail: None,
|
||||
};
|
||||
let update = ide_core::updates::UpdateInfo {
|
||||
version: "9.9.9".into(),
|
||||
current_version: "0.1.0".into(),
|
||||
notes: None,
|
||||
date: None,
|
||||
};
|
||||
for event in [
|
||||
IdeEvent::McpRequest { id: 1, request: confirm },
|
||||
IdeEvent::McpRequestClosed { id: 1 },
|
||||
IdeEvent::UpdateAvailable { update },
|
||||
IdeEvent::UpdateStatusChanged {
|
||||
status: ide_core::updates::UpdateStatus { last_checked: None, checking: true },
|
||||
},
|
||||
IdeEvent::WindowGroupsChanged,
|
||||
IdeEvent::WindowFullscreenChanged,
|
||||
] {
|
||||
assert!(!client_accepts(&event), "{event:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_reports_its_project_to_the_desktop() {
|
||||
for event in [
|
||||
IdeEvent::FsChanged {
|
||||
created: vec!["/srv/app/a".into()],
|
||||
modified: vec![],
|
||||
removed: vec![],
|
||||
ignore_rules: vec![],
|
||||
},
|
||||
IdeEvent::VcsChanged { root: "/srv/app".into() },
|
||||
IdeEvent::Notify { level: NotifyLevel::Info, title: "Build".into(), body: None },
|
||||
IdeEvent::Askpass { id: 1, prompt: "Password".into(), kind: AskpassKind::Secret, remember: false },
|
||||
IdeEvent::ProjectTrustChanged { root: "/srv/app".into(), trusted: true },
|
||||
] {
|
||||
assert!(client_accepts(&event), "{event:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,15 @@ pub struct TrustStore {
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
|
||||
inherited: Mutex<Vec<PathBuf>>,
|
||||
remote_file: PathBuf,
|
||||
remote: Mutex<IdeResult<RemoteDecisions>>,
|
||||
}
|
||||
|
||||
/// This machine's decisions about remote projects, by location (see [`TrustStore::remote_decision`]).
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
struct RemoteDecisions {
|
||||
trusted: Vec<String>,
|
||||
restricted: Vec<String>,
|
||||
}
|
||||
|
||||
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
|
||||
@@ -40,7 +49,37 @@ impl TrustStore {
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
|
||||
let remote_file = data.join("remote-project-trust.json");
|
||||
let remote = match std::fs::read(&remote_file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemoteDecisions::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default(), remote_file, remote: Mutex::new(remote) }
|
||||
}
|
||||
|
||||
/// The decision made on this machine for the remote project at `location` (`None`: none yet).
|
||||
/// Commands a remote window runs here follow it, not the server's own trust.
|
||||
pub fn remote_decision(&self, location: &str) -> IdeResult<Option<bool>> {
|
||||
let remote = self.remote.lock().clone()?;
|
||||
Ok(if remote.restricted.iter().any(|l| l == location) {
|
||||
Some(false)
|
||||
} else if remote.trusted.iter().any(|l| l == location) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decide_remote(&self, location: &str, trusted: bool) -> IdeResult<()> {
|
||||
let mut state = self.remote.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.trusted.retain(|l| l != location);
|
||||
next.restricted.retain(|l| l != location);
|
||||
if trusted { &mut next.trusted } else { &mut next.restricted }.push(location.to_owned());
|
||||
persist(&self.remote_file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
@@ -133,7 +172,7 @@ impl TrustStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
|
||||
fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -372,6 +411,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_trust_status"
|
||||
| "remote_trust_set"
|
||||
| "remote_bootstrap_list"
|
||||
| "wsl_distros"
|
||||
| "wsl_resolve"
|
||||
@@ -526,4 +567,18 @@ mod tests {
|
||||
fn restricted_mode_allows_clipboard_write_text() {
|
||||
assert!(super::restricted_command_allowed("clipboard_write_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_project_decisions_are_kept_on_this_machine() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let load = || super::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
let store = load();
|
||||
let here = r#"[{"kind":"ssh","config":"box"},"/srv/app"]"#;
|
||||
assert_eq!(store.remote_decision(here).unwrap(), None);
|
||||
store.decide_remote(here, true).unwrap();
|
||||
assert_eq!(store.remote_decision(here).unwrap(), Some(true));
|
||||
assert_eq!(store.remote_decision(r#"[{"kind":"ssh","config":"box"},"/srv/other"]"#).unwrap(), None);
|
||||
store.decide_remote(here, false).unwrap();
|
||||
assert_eq!(load().remote_decision(here).unwrap(), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -455,3 +455,29 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
peer.kill();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
// As `openidea-server` assembles its commands.
|
||||
let plugins = ide_host::plugins::remote_host::RemotePlugins::start(&base.join("data"), &host, peer_slot).unwrap();
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
dispatcher.add(ide_host::plugins::remote::SYNC_COMMAND, |_| async { Ok(()) });
|
||||
assert_eq!(rpc::client_commands(), dispatcher.commands());
|
||||
assert!(rpc::client_commands().contains(&"term_spawn"));
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
@@ -83,10 +83,10 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
|
||||
};
|
||||
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
|
||||
std::fs::write(meta(&original), original_metadata).unwrap();
|
||||
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
|
||||
ops::fs::rename(&host, "main", original.clone(), renamed.clone()).await.unwrap();
|
||||
assert!(!original.exists() && !meta(&original).exists());
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
|
||||
ops::fs::copy(&host, "main", renamed.clone(), copied.clone()).await.unwrap();
|
||||
assert!(copied.exists());
|
||||
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
|
||||
@@ -21,6 +21,9 @@ ignore.workspace = true
|
||||
globset.workspace = true
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
|
||||
[build-dependencies]
|
||||
blake3.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Hashes this crate's sources into `INDEX_SOURCE_HASH`: what is extracted and stored for a
|
||||
//! file (ignored state, size limit, record layout) is decided here as well as in
|
||||
//! `ide-language`, so the persistent index is rebuilt when either changes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let src = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src");
|
||||
println!("cargo:rerun-if-changed={}", src.display());
|
||||
let mut files = Vec::new();
|
||||
collect(&src, &mut files);
|
||||
files.sort();
|
||||
let mut h = blake3::Hasher::new();
|
||||
for file in files {
|
||||
h.update(file.strip_prefix(&src).unwrap_or(&file).to_string_lossy().replace('\\', "/").as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&std::fs::read(&file).unwrap());
|
||||
h.update(b"\0");
|
||||
}
|
||||
println!("cargo:rustc-env=INDEX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
|
||||
}
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ impl Store {
|
||||
}
|
||||
};
|
||||
let store = Self { db: RwLock::new(db) };
|
||||
let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display());
|
||||
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
|
||||
match store.meta("stamp") {
|
||||
Ok(Some(v)) if v == stamp.as_bytes() => {}
|
||||
_ => {
|
||||
|
||||
@@ -58,6 +58,9 @@ tree-sitter-graphql = "0.3.0"
|
||||
tree-sitter-prisma-io = "1.6.0"
|
||||
tree-sitter-gdscript = "6.1.0"
|
||||
|
||||
[build-dependencies]
|
||||
blake3.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Hashes everything that decides what `Syntax::tags` extracts (this crate's
|
||||
//! sources, queries, language definitions and the locked tree-sitter crates)
|
||||
//! into `SYNTAX_SOURCE_HASH`, so persistent caches survive releases that leave
|
||||
//! extraction untouched.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let workspace = manifest.join("../..");
|
||||
let lock = workspace.join("Cargo.lock");
|
||||
let inputs = [manifest.join("src"), manifest.join("queries"), workspace.join("languages")];
|
||||
|
||||
let mut h = blake3::Hasher::new();
|
||||
for dir in &inputs {
|
||||
println!("cargo:rerun-if-changed={}", dir.display());
|
||||
let mut files = Vec::new();
|
||||
collect(dir, &mut files);
|
||||
files.sort();
|
||||
for file in files {
|
||||
let rel = file.strip_prefix(&workspace).unwrap_or(&file);
|
||||
h.update(rel.to_string_lossy().replace('\\', "/").as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&std::fs::read(&file).unwrap());
|
||||
h.update(b"\0");
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", lock.display());
|
||||
match std::fs::read_to_string(&lock) {
|
||||
Ok(text) => h.update(tree_sitter_packages(&text).as_bytes()),
|
||||
// Built outside the workspace: fall back to the crate version.
|
||||
Err(_) => h.update(std::env::var("CARGO_PKG_VERSION").unwrap().as_bytes()),
|
||||
};
|
||||
println!("cargo:rustc-env=SYNTAX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
|
||||
}
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `name version checksum` of every locked `tree-sitter*` package.
|
||||
fn tree_sitter_packages(lock: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for package in lock.split("[[package]]") {
|
||||
let field = |key: &str| {
|
||||
package.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix(" = ")).unwrap_or("").trim_matches('"')
|
||||
};
|
||||
if field("name").starts_with("tree-sitter") {
|
||||
out.push_str(&format!("{} {} {}\n", field("name"), field("version"), field("checksum")));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -132,29 +132,11 @@ impl Syntax {
|
||||
SPECS.iter().map(|s| s.name)
|
||||
}
|
||||
|
||||
/// Changes whenever extraction output may change (grammar set, queries,
|
||||
/// crate version); persistent caches of [`Syntax::tags`] key on it.
|
||||
/// Changes whenever extraction output may change (this crate's sources,
|
||||
/// queries, language definitions, tree-sitter crate versions; see
|
||||
/// `build.rs`); persistent caches of [`Syntax::tags`] key on it.
|
||||
pub fn fingerprint(&self) -> String {
|
||||
static FP: OnceLock<String> = OnceLock::new();
|
||||
FP.get_or_init(|| {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(env!("CARGO_PKG_VERSION").as_bytes());
|
||||
// Bump when web definitions (`.class`, `#id`, `--var` tags) change.
|
||||
h.update(b"web-definitions-1");
|
||||
for s in SPECS {
|
||||
h.update(s.name.as_bytes());
|
||||
h.update(&Language::new(s.language).abi_version().to_le_bytes());
|
||||
h.update(s.outline.as_bytes());
|
||||
for list in [s.fold_nodes, s.fold_bodies, s.imports, s.constructors] {
|
||||
for k in list {
|
||||
h.update(k.as_bytes());
|
||||
h.update(b"\0");
|
||||
}
|
||||
}
|
||||
}
|
||||
h.finalize().to_hex()[..16].to_owned()
|
||||
})
|
||||
.clone()
|
||||
env!("SYNTAX_SOURCE_HASH").to_owned()
|
||||
}
|
||||
|
||||
/// Whether a tree-sitter grammar is bundled for this language.
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! The version-control backend abstraction (#743).
|
||||
//!
|
||||
//! `ide-vcs` grew up Git-only: [`Repo`] talks to git through gitoxide and the
|
||||
//! `git` CLI. A second backend (Perforce first, maybe SVN later) must serve the
|
||||
//! same UI — the Commit / Local Changes tool window, the diff viewer, the log
|
||||
//! and Annotate — without that UI learning backend specifics.
|
||||
//!
|
||||
//! [`Backend`] is that surface: status of the working area, the text and mode
|
||||
//! of a file at a revision, unified diffs, line hunks and annotate, the change
|
||||
//! log, and the writes a user makes from the Changes view — stage ("open for
|
||||
//! edit/add" in Perforce), discard ("revert") and commit ("submit").
|
||||
//!
|
||||
//! Deliberately Git-only concepts stay on [`Repo`]: branches and remotes,
|
||||
//! stash, rebase/merge/cherry-pick, tags, worktrees, the index's staged /
|
||||
//! unstaged split beyond what [`FileStatus`] already carries, and Restricted
|
||||
//! Mode. A caller that needs them downcasts through [`Backend::as_git`]; a
|
||||
//! caller that must work for every backend uses only the trait. As Perforce
|
||||
//! support lands, methods that turn out to be needed generically move here.
|
||||
|
||||
use crate::{
|
||||
BlameLine, BlockHistory, ChangedFile, Commit, CommitOptions, FileStatus, LineHunk, LogQuery, PendingChange, Repo,
|
||||
RevisionChange, TextEncoding,
|
||||
};
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The version-control surface the Changes view, diff viewer, annotate and log
|
||||
/// need from a repository. Implemented by every supported backend; today that
|
||||
/// is Git via [`Repo`].
|
||||
///
|
||||
/// Revision strings follow the backend's own syntax: git revisions (`HEAD`,
|
||||
/// `:0` for the index, ids, `<id>^`…) for the Git backend; a Perforce backend
|
||||
/// takes `#have`, `@label` or a change number, and its "index" side is the
|
||||
/// opened-file state.
|
||||
pub trait Backend: Send + Sync {
|
||||
/// The repository's working root.
|
||||
fn root(&self) -> &Path;
|
||||
|
||||
/// Working-area changes, in the same shape `git status` gives: staged and
|
||||
/// unstaged kinds per path. A backend without an index reports everything
|
||||
/// as `unstaged`.
|
||||
fn status(&self) -> IdeResult<Vec<FileStatus>>;
|
||||
|
||||
/// Text of `path` at `rev`; `None` when absent there.
|
||||
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>>;
|
||||
|
||||
/// The file's mode at `rev` in the backend's own notation (git's `100644`,
|
||||
/// `100755`, `120000`…; a Perforce backend reports the filetype, where
|
||||
/// `+x` is the executable case). `None` when absent there. The diff viewer
|
||||
/// shows "File mode changed" when the texts compare equal but the modes
|
||||
/// differ. `rev` follows [`file_at`](Self::file_at)'s rules, `WT` reads the
|
||||
/// worktree.
|
||||
fn file_mode_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>>;
|
||||
|
||||
/// Unified diff text of `path`'s working change (against the depot/HEAD
|
||||
/// side), empty when unchanged.
|
||||
fn diff_text(&self, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult<String>;
|
||||
|
||||
/// Working tree against `against` (or the base revision) as a patch,
|
||||
/// optionally restricted to a file.
|
||||
fn working_tree_patch(
|
||||
&self,
|
||||
path: Option<&Path>,
|
||||
against: Option<&str>,
|
||||
max_bytes: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String>;
|
||||
|
||||
/// Gutter markers: `current_text`'s hunks against the base.
|
||||
fn line_hunks(
|
||||
&self,
|
||||
path: &Path,
|
||||
current_text: &str,
|
||||
against_index: bool,
|
||||
encoding: TextEncoding,
|
||||
) -> IdeResult<Vec<LineHunk>>;
|
||||
|
||||
/// Annotate: who last changed each line of `path` (`text` when given is the
|
||||
/// buffer, not the disk file).
|
||||
fn blame(
|
||||
&self,
|
||||
path: &Path,
|
||||
text: Option<&str>,
|
||||
revision: Option<&str>,
|
||||
encoding: TextEncoding,
|
||||
) -> IdeResult<Vec<BlameLine>>;
|
||||
|
||||
/// History for Selection: the changes that touched `lines` (0-based) of
|
||||
/// `text` (the buffer, or the disk file when `None`).
|
||||
fn block_history(
|
||||
&self,
|
||||
path: &Path,
|
||||
text: Option<&str>,
|
||||
lines: (u32, u32),
|
||||
encoding: TextEncoding,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<BlockHistory>;
|
||||
|
||||
/// The change log, newest first.
|
||||
fn log(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>>;
|
||||
|
||||
/// Files `commit` changed.
|
||||
fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>>;
|
||||
|
||||
/// The per-file changes a set of commits made, for a combined diff.
|
||||
fn commits_changes(&self, commits: &[String]) -> IdeResult<Vec<RevisionChange>>;
|
||||
|
||||
/// Changes between two revisions.
|
||||
fn diff_revisions(&self, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>>;
|
||||
|
||||
/// Files differing between `rev` and the worktree.
|
||||
fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>>;
|
||||
|
||||
/// Per-file line counts of `diff_with_worktree`.
|
||||
fn numstat_with_worktree(&self, rev: &str) -> IdeResult<Vec<(String, u32, u32)>>;
|
||||
|
||||
// ---- working-area writes ----
|
||||
|
||||
/// Marks `paths` for commit: `git add`, `p4 edit`/`p4 add`. For a file a
|
||||
/// backend stores read-only until opened, this is checkout-on-edit too.
|
||||
fn stage(&self, paths: &[PathBuf]) -> IdeResult<()>;
|
||||
|
||||
/// Reverses [`stage`](Self::stage) without touching the worktree
|
||||
/// (`git restore --staged`, `p4 revert -k` equivalent).
|
||||
fn unstage(&self, paths: &[PathBuf]) -> IdeResult<()>;
|
||||
|
||||
/// Discards working-area changes: tracked paths return to the base,
|
||||
/// untracked ones are deleted (`git restore`/`clean`, `p4 revert`).
|
||||
fn discard(&self, paths: &[PathBuf]) -> IdeResult<()>;
|
||||
|
||||
/// Records the staged/opened changes as a new change (`git commit`,
|
||||
/// `p4 submit`); returns its id. `encoding` resolves each file's text
|
||||
/// encoding (the caller knows the open documents' settings).
|
||||
fn commit(
|
||||
&self,
|
||||
message: &str,
|
||||
options: &CommitOptions,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<String>;
|
||||
|
||||
/// What [`commit`](Self::commit) would record, without committing.
|
||||
fn pending_changes(
|
||||
&self,
|
||||
options: &CommitOptions,
|
||||
max_bytes: usize,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<Vec<PendingChange>>;
|
||||
|
||||
// ---- IDE file operations ----
|
||||
|
||||
/// Files or folders the IDE created (project tree New File, refactorings).
|
||||
/// A checkout backend opens them for add (`p4 add`); Git tracks new files
|
||||
/// by content, so this is a no-op. Called after the files exist on disk;
|
||||
/// a failure leaves them as ordinary untracked files.
|
||||
fn files_added(&self, _paths: &[PathBuf]) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files or folders the IDE deleted (project tree Delete, refactorings).
|
||||
/// A checkout backend opens them for delete (`p4 delete`); Git records the
|
||||
/// deletion at commit time, so this is a no-op. Called after they are gone
|
||||
/// from disk.
|
||||
fn files_deleted(&self, _paths: &[PathBuf]) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file or folder the IDE moved or renamed (project tree Rename/Move,
|
||||
/// refactorings). A checkout backend records the rename (`p4 move`) so
|
||||
/// history follows the file; Git detects renames from content, so this is
|
||||
/// a no-op. Called after `from` is at `to` on disk.
|
||||
fn file_moved(&self, _from: &Path, _to: &Path) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The concrete Git repository, when this backend is one — for the
|
||||
/// Git-only surface (branches, remotes, stash, rebase…). `None` for other
|
||||
/// backends; callers should degrade to hiding Git-only UI.
|
||||
fn as_git(&self) -> Option<&Repo> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Repo {
|
||||
fn root(&self) -> &Path {
|
||||
Repo::root(self)
|
||||
}
|
||||
|
||||
fn status(&self) -> IdeResult<Vec<FileStatus>> {
|
||||
Repo::status(self)
|
||||
}
|
||||
|
||||
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>> {
|
||||
Repo::file_at(self, path, rev, encoding)
|
||||
}
|
||||
|
||||
fn file_mode_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
|
||||
Repo::file_mode_at(self, path, rev)
|
||||
}
|
||||
|
||||
fn diff_text(&self, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult<String> {
|
||||
Repo::diff_text(self, path, staged, encoding)
|
||||
}
|
||||
|
||||
fn working_tree_patch(
|
||||
&self,
|
||||
path: Option<&Path>,
|
||||
against: Option<&str>,
|
||||
max_bytes: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
Repo::working_tree_patch(self, path, against, max_bytes, cancel)
|
||||
}
|
||||
|
||||
fn line_hunks(
|
||||
&self,
|
||||
path: &Path,
|
||||
current_text: &str,
|
||||
against_index: bool,
|
||||
encoding: TextEncoding,
|
||||
) -> IdeResult<Vec<LineHunk>> {
|
||||
Repo::line_hunks(self, path, current_text, against_index, encoding)
|
||||
}
|
||||
|
||||
fn blame(
|
||||
&self,
|
||||
path: &Path,
|
||||
text: Option<&str>,
|
||||
revision: Option<&str>,
|
||||
encoding: TextEncoding,
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
Repo::blame(self, path, text, revision, encoding)
|
||||
}
|
||||
|
||||
fn block_history(
|
||||
&self,
|
||||
path: &Path,
|
||||
text: Option<&str>,
|
||||
lines: (u32, u32),
|
||||
encoding: TextEncoding,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<BlockHistory> {
|
||||
Repo::block_history(self, path, text, lines, encoding, cancel)
|
||||
}
|
||||
|
||||
fn log(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
|
||||
Repo::log_cancellable(self, query, cancel)
|
||||
}
|
||||
|
||||
fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
Repo::commit_files(self, commit)
|
||||
}
|
||||
|
||||
fn commits_changes(&self, commits: &[String]) -> IdeResult<Vec<RevisionChange>> {
|
||||
Repo::commits_changes(self, commits)
|
||||
}
|
||||
|
||||
fn diff_revisions(&self, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
Repo::diff_revisions(self, from, to)
|
||||
}
|
||||
|
||||
fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
Repo::diff_with_worktree(self, rev)
|
||||
}
|
||||
|
||||
fn numstat_with_worktree(&self, rev: &str) -> IdeResult<Vec<(String, u32, u32)>> {
|
||||
Repo::numstat_with_worktree(self, rev)
|
||||
}
|
||||
|
||||
fn stage(&self, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
Repo::stage(self, paths)
|
||||
}
|
||||
|
||||
fn unstage(&self, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
Repo::unstage(self, paths)
|
||||
}
|
||||
|
||||
fn discard(&self, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
Repo::discard(self, paths)
|
||||
}
|
||||
|
||||
fn commit(
|
||||
&self,
|
||||
message: &str,
|
||||
options: &CommitOptions,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<String> {
|
||||
Repo::commit_with(self, message, options, encoding)
|
||||
}
|
||||
|
||||
fn pending_changes(
|
||||
&self,
|
||||
options: &CommitOptions,
|
||||
max_bytes: usize,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<Vec<PendingChange>> {
|
||||
Repo::pending_changes(self, options, max_bytes, encoding)
|
||||
}
|
||||
|
||||
fn as_git(&self) -> Option<&Repo> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
@@ -610,6 +610,7 @@ pub struct ChangelistState {
|
||||
pub files: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
mod backend;
|
||||
mod bek;
|
||||
mod blame;
|
||||
mod block_history;
|
||||
@@ -637,6 +638,7 @@ pub use index_writes::is_own_index_write;
|
||||
mod text_encoding;
|
||||
mod write;
|
||||
|
||||
pub use backend::Backend;
|
||||
pub use cli::{
|
||||
GitCommandObserver, detected_git_executable, filter_overrides_from, git_version, restrict, set_askpass_env,
|
||||
set_git_command_observer, set_git_executable,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The repositories of a project (IDEA's VCS roots): the one containing the project
|
||||
//! root plus those nested below it (separate clones side by side, submodules).
|
||||
|
||||
use crate::{FileStatus, Repo};
|
||||
use crate::{Backend, FileStatus, Repo};
|
||||
use ide_core::IdeResult;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -142,6 +142,9 @@ impl Repos {
|
||||
/// Changes of every repository. A nested repository reports its own files, so the
|
||||
/// enclosing one's "untracked folder" entry for it is dropped. A repository that cannot
|
||||
/// be read (e.g. deleted since the last scan) is left out; fails only when all fail.
|
||||
///
|
||||
/// The reads go through [`Backend`], the Changes-view surface — repositories of a
|
||||
/// future second backend slot in here once `Repos` learns to hold them.
|
||||
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
|
||||
let mut out = Vec::new();
|
||||
let mut first_error = None;
|
||||
@@ -153,7 +156,7 @@ impl Repos {
|
||||
.map(|r| r.root())
|
||||
.filter(|r| *r != repo.root() && r.starts_with(repo.root()))
|
||||
.collect();
|
||||
let status = match repo.status() {
|
||||
let status = match (repo.as_ref() as &dyn Backend).status() {
|
||||
Ok(status) => {
|
||||
read += 1;
|
||||
status
|
||||
|
||||
@@ -9,7 +9,8 @@ const ROOT = "/dev/acme-api";
|
||||
|
||||
async function openBranches(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const { executeAction } = await import(/* @vite-ignore */ "/src/platform/actions.ts");
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
await executeAction("vcs.branches");
|
||||
});
|
||||
const popup = page.getByRole("dialog", { name: "Git Branches" });
|
||||
@@ -48,7 +49,8 @@ test.beforeEach(async ({ workbench: page }) => {
|
||||
branch("main", true),
|
||||
...Array.from({ length: 40 }, (_, i) => branch(`task-${i}-a-long-branch-name-that-gets-cut-off`)),
|
||||
];
|
||||
const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ "/src/features/vcs/store.ts");
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
await loadBranches();
|
||||
|
||||
@@ -74,3 +74,26 @@ test("⌘K commits the checked changes with the typed message", async ({ workben
|
||||
expect(await ipcCalls(page, "vcs_stage")).toHaveLength(0);
|
||||
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("commit message history opens above the message without an empty icon column", async ({ workbench: page }) => {
|
||||
await stubRepository(page);
|
||||
await page.keyboard.press("Meta+k");
|
||||
const message = page.getByRole("textbox", { name: "Commit message" });
|
||||
await expect(message).toBeVisible();
|
||||
await page.evaluate((root) => {
|
||||
localStorage.setItem(`openidea.vcs.commitMessages:${root}`, JSON.stringify(["Previous commit message"]));
|
||||
}, ROOT);
|
||||
await message.fill("Draft");
|
||||
await page.getByRole("button", { name: "Commit Message History" }).click();
|
||||
|
||||
const menu = page.getByRole("menu", { name: "Recent commit messages" });
|
||||
const row = menu.getByRole("menuitem", { name: "Previous commit message" });
|
||||
await expect(row).toBeVisible();
|
||||
const messageBox = await message.boundingBox();
|
||||
const menuBox = await menu.locator("..").boundingBox();
|
||||
expect(messageBox).not.toBeNull();
|
||||
expect(menuBox).not.toBeNull();
|
||||
expect(menuBox!.y + menuBox!.height).toBeLessThan(messageBox!.y);
|
||||
expect(Math.abs(menuBox!.x - messageBox!.x)).toBeLessThan(2);
|
||||
await expect(row.locator("span")).toHaveCount(1);
|
||||
});
|
||||
@@ -53,8 +53,13 @@ pub fn fs_create_file(window: Window, state: State<'_, AppState>, path: PathBuf)
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_create_file_bytes(state: State<'_, AppState>, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
fs::create_file_bytes(&state.host, path, data).await
|
||||
pub async fn fs_create_file_bytes(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
data: Vec<u8>,
|
||||
) -> IdeResult<()> {
|
||||
fs::create_file_bytes(&state.host, window.label(), path, data).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -65,8 +70,8 @@ pub fn fs_create_dir(path: PathBuf) -> IdeResult<()> {
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_rename(state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
fs::rename(&state.host, from, to).await
|
||||
pub async fn fs_rename(window: Window, state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
fs::rename(&state.host, window.label(), from, to).await
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
@@ -86,8 +91,8 @@ pub async fn fs_delete(window: Window, state: State<'_, AppState>, path: PathBuf
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_copy(state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
fs::copy(&state.host, from, to).await
|
||||
pub async fn fs_copy(window: Window, state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
fs::copy(&state.host, window.label(), from, to).await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document (previews, quick
|
||||
|
||||
@@ -699,6 +699,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
crate::remote_dev::remote_bootstrap_list,
|
||||
crate::remote_dev::remote_connect,
|
||||
crate::remote_dev::remote_current,
|
||||
crate::remote_dev::remote_trust_status,
|
||||
crate::remote_dev::remote_trust_set,
|
||||
crate::remote_dev::remote_disconnect,
|
||||
crate::remote_dev::remote_forwarded_ports,
|
||||
crate::remote_dev::remote_forward_port,
|
||||
|
||||
+6
-24
@@ -162,30 +162,12 @@ pub fn run() {
|
||||
let webview = request.message.webview_ref();
|
||||
if let Some(state) = webview.try_state::<AppState>() {
|
||||
let label = AppState::owner_window(webview.label());
|
||||
if let Some(connection) = state.remote_dev.get(label) {
|
||||
let label = label.to_owned();
|
||||
let command = request.message.command().to_owned();
|
||||
let invoke = invoke.clone();
|
||||
let app = webview.app_handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match connection.authorize_command(&label, &command).await {
|
||||
Ok(()) => {
|
||||
let resolver = request.resolver.clone();
|
||||
// Sync commands must run on the main thread, as they do in local windows.
|
||||
if let Err(error) = app.run_on_main_thread(move || {
|
||||
invoke(request);
|
||||
}) {
|
||||
resolver.reject(ide_core::IdeError::internal(format!(
|
||||
"failed to dispatch command on the main thread: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Err(error) => request.resolver.reject(error),
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if let Err(error) = state.host.authorize_command(webview.label(), request.message.command()) {
|
||||
let command = request.message.command();
|
||||
let authorized = match state.remote_dev.get(label) {
|
||||
Some(connection) => connection.authorize_command(&state.host.trust, command),
|
||||
None => state.host.authorize_command(webview.label(), command),
|
||||
};
|
||||
if let Err(error) = authorized {
|
||||
request.resolver.reject(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
+140
-19
@@ -104,25 +104,35 @@ type Channels = Arc<Mutex<HashMap<u64, (Channel<ChannelFrame>, u64)>>>;
|
||||
pub struct Connections(Mutex<HashMap<String, Arc<Connection>>>);
|
||||
|
||||
impl Connection {
|
||||
pub async fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
|
||||
if ide_host::trust::restricted_command_allowed(command) {
|
||||
return Ok(());
|
||||
}
|
||||
let project: Option<ide_host::ops::project::ProjectInfo> = self
|
||||
.peer
|
||||
.request(
|
||||
"invoke",
|
||||
Invoke { window: window.to_owned(), cmd: "project_current".into(), args: serde_json::json!({}) },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(ide_host::rpc::error_from_reply)?;
|
||||
if project.is_some_and(|p| p.trusted) {
|
||||
/// Commands of a remote window that run on this machine follow this machine's decision about the
|
||||
/// remote project, never the server's: the server only answers for what runs there.
|
||||
pub fn authorize_command(&self, trust: &ide_host::trust::TrustStore, command: &str) -> IdeResult<()> {
|
||||
if ide_host::trust::restricted_command_allowed(command) || self.trusted_here(trust) == Some(true) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable("Project is in Restricted Mode"))
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine's decision about the project (`None`: none yet). A dev container is its local
|
||||
/// folder's project, trusted before it was started.
|
||||
pub fn trusted_here(&self, trust: &ide_host::trust::TrustStore) -> Option<bool> {
|
||||
match trust.remote_decision(&self.location()) {
|
||||
Ok(Some(decision)) => Some(decision),
|
||||
Ok(None) => match &self.target {
|
||||
RemoteTarget::DevContainer { folder, .. } => {
|
||||
trust.trusted(std::path::Path::new(folder)).unwrap_or(false).then_some(true)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Err(_) => Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of this project's decisions on this machine.
|
||||
fn location(&self) -> String {
|
||||
serde_json::to_string(&(&self.target, &self.root)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Connections {
|
||||
@@ -197,7 +207,7 @@ pub struct RemoteProject {
|
||||
pub host: String,
|
||||
pub server_version: String,
|
||||
pub os: String,
|
||||
/// Commands the server answers (the rest run locally).
|
||||
/// Commands routed to the server (the rest run locally).
|
||||
pub commands: Vec<String>,
|
||||
pub project: ide_host::ops::project::ProjectInfo,
|
||||
}
|
||||
@@ -212,11 +222,12 @@ impl Handler for ClientHandler {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
match method {
|
||||
"event" => match serde_json::from_value::<EventMessage>(params) {
|
||||
Ok(m) => {
|
||||
Ok(m) if ide_host::rpc::client_accepts(&m.event) => {
|
||||
// The server's windows are this window.
|
||||
let _ = m.window;
|
||||
TauriSink { app: self.app.clone(), window: Some(self.window.clone()) }.emit(m.event);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!("remote event: {e}"),
|
||||
},
|
||||
// Plugin code of this window's project runs on the server.
|
||||
@@ -327,6 +338,12 @@ pub(crate) fn platform(uname: &str) -> (String, String) {
|
||||
(os, arch)
|
||||
}
|
||||
|
||||
/// An absolute path on a server (Linux or macOS): never one this machine would read as a network
|
||||
/// share or a device (`//host/share`, `\\host\share`, `//./pipe`).
|
||||
fn server_path(path: &str) -> bool {
|
||||
path.starts_with('/') && !path.starts_with("//") && !path.contains('\\')
|
||||
}
|
||||
|
||||
/// What [`local_server`] settled on.
|
||||
pub(crate) enum LocalServer {
|
||||
/// A server binary on this machine and its [`build_id`].
|
||||
@@ -519,8 +536,7 @@ pub async fn remote_connect(
|
||||
ide_host::rpc::PROTOCOL_VERSION
|
||||
)));
|
||||
}
|
||||
let commands: Vec<String> =
|
||||
peer.request("commands", Value::Null, None).await.map_err(ide_host::rpc::error_from_reply)?;
|
||||
let commands: Vec<String> = ide_host::rpc::client_commands().into_iter().map(str::to_owned).collect();
|
||||
let root = if path.trim().is_empty() || path.trim() == "~" {
|
||||
info.home.clone()
|
||||
} else if let Some(rest) = path.trim().strip_prefix("~/") {
|
||||
@@ -534,6 +550,9 @@ pub async fn remote_connect(
|
||||
peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?;
|
||||
// As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's.
|
||||
let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root);
|
||||
if !server_path(&root) {
|
||||
return Err(IdeError::invalid(format!("openidea-server opened an unexpected project folder: {root}")));
|
||||
}
|
||||
let _ = window.set_title(&format!("{} [{title_tag}]", project.display_name));
|
||||
let connection = Arc::new(Connection {
|
||||
peer,
|
||||
@@ -674,6 +693,27 @@ pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option<Remo
|
||||
})
|
||||
}
|
||||
|
||||
/// This machine's trust decision for the window's remote project (see [`Connection::authorize_command`]).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn remote_trust_status(window: Window, state: State<'_, AppState>) -> IdeResult<ide_host::trust::TrustStatus> {
|
||||
let c = connection(&window, &state)?;
|
||||
let decision = c.trusted_here(&state.host.trust);
|
||||
Ok(ide_host::trust::TrustStatus {
|
||||
root: c.root.clone().into(),
|
||||
trusted: decision == Some(true),
|
||||
decided: decision.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Records the user's trust decision for the window's remote project on this machine.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn remote_trust_set(window: Window, state: State<'_, AppState>, trusted: bool) -> IdeResult<()> {
|
||||
let c = connection(&window, &state)?;
|
||||
state.host.trust.decide_remote(&c.location(), trusted)
|
||||
}
|
||||
|
||||
/// A port of the remote host reachable on this machine at `localhost:local_port`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -925,7 +965,7 @@ pub async fn remote_import_local(
|
||||
let Some(session) = c.session.clone() else {
|
||||
return Err(IdeError::unavailable("Importing local files is not supported in a WSL project yet"));
|
||||
};
|
||||
c.authorize_command(label, "fs_copy").await?;
|
||||
c.authorize_command(&state.host.trust, "fs_copy")?;
|
||||
let cancel = state.host.begin_request(request);
|
||||
let result = async {
|
||||
let fs = ide_ssh::RemoteFs::new(session.sftp().await?);
|
||||
@@ -971,6 +1011,87 @@ mod tests {
|
||||
peer.kill();
|
||||
}
|
||||
|
||||
/// A server that reports its project as trusted.
|
||||
struct TrustingServer;
|
||||
|
||||
impl Handler for TrustingServer {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
|
||||
Ok(serde_json::to_value(ide_host::ops::project::ProjectInfo {
|
||||
trusted: true,
|
||||
root: "/srv/app".into(),
|
||||
name: "app".into(),
|
||||
display_name: "app".into(),
|
||||
is_git: false,
|
||||
settings: Default::default(),
|
||||
workspace: None,
|
||||
unity: None,
|
||||
})
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
fn connected(target: RemoteTarget, root: &str) -> Connection {
|
||||
let (client, server) = tokio::io::duplex(1 << 16);
|
||||
let (reader, writer) = tokio::io::split(client);
|
||||
let peer = Peer::connect(reader, writer, Arc::new(ide_jsonrpc::NoopHandler));
|
||||
let (reader, writer) = tokio::io::split(server);
|
||||
std::mem::forget(Peer::connect(reader, writer, Arc::new(TrustingServer)));
|
||||
Connection {
|
||||
peer,
|
||||
target,
|
||||
root: root.into(),
|
||||
host: "host".into(),
|
||||
os: "linux".into(),
|
||||
commands: Vec::new(),
|
||||
channels: Arc::default(),
|
||||
next_channel: std::sync::atomic::AtomicU64::new(1),
|
||||
session: None,
|
||||
exec: None,
|
||||
forwards: Mutex::new(Vec::new()),
|
||||
web_forwards: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_server_does_not_decide_trust_for_commands_run_here() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust = ide_host::trust::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
let c = connected(RemoteTarget::Ssh { config: "box".into() }, "/srv/app");
|
||||
let error = c.authorize_command(&trust, "plugin_grant").unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(c.authorize_command(&trust, "fs_read_text").is_ok());
|
||||
trust.decide_remote(&c.location(), true).unwrap();
|
||||
assert!(c.authorize_command(&trust, "plugin_grant").is_ok());
|
||||
let other = connected(RemoteTarget::Ssh { config: "box".into() }, "/srv/other");
|
||||
assert!(other.authorize_command(&trust, "plugin_grant").is_err());
|
||||
trust.decide_remote(&c.location(), false).unwrap();
|
||||
assert!(c.authorize_command(&trust, "plugin_grant").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dev_container_follows_its_local_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let folder = tmp.path().join("app");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
let trust = ide_host::trust::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
let target =
|
||||
RemoteTarget::DevContainer { folder: folder.to_string_lossy().into_owned(), config: String::new() };
|
||||
let c = connected(target, "/workspaces/app");
|
||||
assert_eq!(c.trusted_here(&trust), None);
|
||||
trust.decide(&folder, true, false).unwrap();
|
||||
assert_eq!(c.trusted_here(&trust), Some(true));
|
||||
assert!(c.authorize_command(&trust, "plugin_grant").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_project_folder_is_never_a_share_or_device_here() {
|
||||
assert!(server_path("/home/me/app"));
|
||||
for path in [r"\\host\share", "//host/share", "//./pipe/x", "//?/UNC/host/share", "home/me", r"/home/a\b", ""] {
|
||||
assert!(!server_path(path), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_uname_to_rust_platform_names() {
|
||||
assert_eq!(platform("Linux x86_64"), ("linux".into(), "x86_64".into()));
|
||||
|
||||
+6
-2
@@ -1634,6 +1634,10 @@ export const commands = {
|
||||
os: string,
|
||||
commands: string[],
|
||||
} | null>("remote_current"),
|
||||
/** This machine's trust decision for the window's remote project (see [`Connection::authorize_command`]). */
|
||||
remoteTrustStatus: () => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("remote_trust_status")),
|
||||
/** Records the user's trust decision for the window's remote project on this machine. */
|
||||
remoteTrustSet: (trusted: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("remote_trust_set", { trusted })),
|
||||
remoteDisconnect: () => __TAURI_INVOKE<void>("remote_disconnect"),
|
||||
/** The remote ports this window forwards to this machine. */
|
||||
remoteForwardedPorts: () => __TAURI_INVOKE<ForwardedPort[]>("remote_forwarded_ports"),
|
||||
@@ -8720,7 +8724,7 @@ export type RemoteProject_Deserialize = {
|
||||
host: string,
|
||||
serverVersion: string,
|
||||
os: string,
|
||||
/** Commands the server answers (the rest run locally). */
|
||||
/** Commands routed to the server (the rest run locally). */
|
||||
commands: string[],
|
||||
project: ProjectInfo_Deserialize,
|
||||
};
|
||||
@@ -8732,7 +8736,7 @@ export type RemoteProject_Serialize = {
|
||||
host: string,
|
||||
serverVersion: string,
|
||||
os: string,
|
||||
/** Commands the server answers (the rest run locally). */
|
||||
/** Commands routed to the server (the rest run locally). */
|
||||
commands: string[],
|
||||
project: ProjectInfo_Serialize,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.value {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-small);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Plugin code of a remote window runs on its server. Opening a link or writing
|
||||
* the clipboard on this machine waits for the user, who sees exactly what.
|
||||
*/
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { usePlugins } from "./store";
|
||||
import styles from "./remoteAsk.module.css";
|
||||
|
||||
const PREVIEW_CHARS = 500;
|
||||
const PREVIEW_LINES = 12;
|
||||
|
||||
const pluginName = (pluginId: string) => usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId;
|
||||
|
||||
/** Whether the user lets a remote plugin open `url` with this machine's browser. */
|
||||
export async function confirmRemoteOpen(pluginId: string, url: string): Promise<boolean> {
|
||||
const name = pluginName(pluginId);
|
||||
const r = await ask({
|
||||
title: tx`Open a link from ${name}?`,
|
||||
body: (
|
||||
<>
|
||||
<div>{tx("A plugin running on the remote host wants to open this address on this computer:")}</div>
|
||||
<div className={styles.value}>{url}</div>
|
||||
</>
|
||||
),
|
||||
buttons: [{ id: "open", label: tx("Open"), primary: true }],
|
||||
});
|
||||
return r.button === "open";
|
||||
}
|
||||
|
||||
/** Whether the user lets a remote plugin put `text` on this machine's clipboard. */
|
||||
export async function confirmRemoteCopy(pluginId: string, text: string): Promise<boolean> {
|
||||
const name = pluginName(pluginId);
|
||||
const preview = previewOf(text);
|
||||
const count = text.length.toLocaleString();
|
||||
const r = await ask({
|
||||
title: tx`Copy text from ${name}?`,
|
||||
body: (
|
||||
<>
|
||||
<div>
|
||||
{tx("A plugin running on the remote host wants to copy this text to the clipboard of this computer:")}
|
||||
</div>
|
||||
<div className={styles.value}>{preview}</div>
|
||||
{preview !== text && <div className={styles.note}>{tx`${count} characters in total`}</div>}
|
||||
</>
|
||||
),
|
||||
buttons: [{ id: "copy", label: tx("Copy"), primary: true }],
|
||||
});
|
||||
return r.button === "copy";
|
||||
}
|
||||
|
||||
/** The start of `text`, at most `PREVIEW_CHARS` characters on `PREVIEW_LINES` lines, marked when cut. */
|
||||
function previewOf(text: string): string {
|
||||
let preview = text.slice(0, PREVIEW_CHARS);
|
||||
const lines = preview.split("\n");
|
||||
if (lines.length > PREVIEW_LINES) preview = lines.slice(0, PREVIEW_LINES).join("\n");
|
||||
return preview.length < text.length ? `${preview}…` : text;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { RemoteSession } from "@/bindings";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { MessagesHost } from "@/workbench/messages";
|
||||
import { handleRuntimeEvent } from "./runtimeUi";
|
||||
|
||||
const URL_FROM_SERVER = "https://example.com/login?state=abc&redirect=https%3A%2F%2Fexample.org%2Fcallback";
|
||||
const remote: RemoteSession = {
|
||||
target: { kind: "ssh", config: "box" },
|
||||
root: "/srv/app",
|
||||
host: "box",
|
||||
os: "linux",
|
||||
commands: [],
|
||||
};
|
||||
|
||||
describe("links and clipboard writes from a remote plugin", () => {
|
||||
let open: ReturnType<typeof vi.spyOn>;
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
||||
useRemote.setState({ session: remote });
|
||||
render(<MessagesHost />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
open.mockRestore();
|
||||
useRemote.setState({ session: null });
|
||||
});
|
||||
|
||||
it("opens a link only after the user confirms its full address", async () => {
|
||||
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: URL_FROM_SERVER }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent(URL_FROM_SERVER);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Open" })));
|
||||
expect(open).toHaveBeenCalledWith(URL_FROM_SERVER, "_blank", "noopener");
|
||||
});
|
||||
|
||||
it("opens nothing when the user cancels", async () => {
|
||||
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: URL_FROM_SERVER }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not ask about addresses it would never open", async () => {
|
||||
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: "file:///etc/passwd" }));
|
||||
await act(async () => {});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies only after the user confirms the text", async () => {
|
||||
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "token-123" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("token-123");
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Copy" })));
|
||||
expect(writeText).toHaveBeenCalledWith("token-123");
|
||||
});
|
||||
|
||||
it("copies nothing when the user cancels", async () => {
|
||||
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "token-123" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews long text shortened, and copies all of it", async () => {
|
||||
const text = `${"a".repeat(2000)}TAIL`;
|
||||
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).not.toHaveTextContent("TAIL");
|
||||
expect(dialog).toHaveTextContent(/2,?004 characters in total/);
|
||||
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Copy" })));
|
||||
expect(writeText).toHaveBeenCalledWith(text);
|
||||
});
|
||||
|
||||
it("acts at once for plugins running on this machine", () => {
|
||||
useRemote.setState({ session: null });
|
||||
handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "local" });
|
||||
handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: "https://example.com/" });
|
||||
expect(writeText).toHaveBeenCalledWith("local");
|
||||
expect(open).toHaveBeenCalledWith("https://example.com/", "_blank", "noopener");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -33,9 +33,10 @@ import { editorsFor, onEditorEvent } from "@/editor/api";
|
||||
import { executeAction, executeActionOrThrow, getAction, onActionExecuted } from "@/platform/actions";
|
||||
import { clearContext, setContext, type ContextValue } from "@/platform/context";
|
||||
import { isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { isOpenable, openExternalUrl } from "@/platform/openUrl";
|
||||
import { tx, VerbatimTextContext } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { ipcPath } from "@/platform/paths";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { BarButton } from "@/ui/BarButton";
|
||||
@@ -46,6 +47,7 @@ import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { editorContext } from "./editorContext";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { askNetPermission } from "./netAsk";
|
||||
import { confirmRemoteCopy, confirmRemoteOpen } from "./remoteAsk";
|
||||
import { usePluginT } from "./pluginText";
|
||||
import { setPluginOutput } from "./views/outputStore";
|
||||
import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor";
|
||||
@@ -605,13 +607,15 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
|
||||
return true;
|
||||
case "copyToClipboard":
|
||||
void writeClipboardText(e.text);
|
||||
if (isRemoteWindow()) void copyRemoteText(e.pluginId, e.text);
|
||||
else void writeClipboardText(e.text);
|
||||
return true;
|
||||
case "openProject":
|
||||
void executeAction("file.openProjectPath", { path: e.path, newWindow: e.newWindow });
|
||||
return true;
|
||||
case "openUrl":
|
||||
void openExternalUrl(e.url);
|
||||
if (isRemoteWindow()) void openRemoteUrl(e.pluginId, e.url);
|
||||
else void openExternalUrl(e.url);
|
||||
return true;
|
||||
case "promptRequested":
|
||||
void answerPrompt(e.promptId, e.prompt, e.pluginId);
|
||||
@@ -634,6 +638,16 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** A remote window's plugin code runs on its server: what it opens here, the user confirms first. */
|
||||
async function openRemoteUrl(pluginId: string, url: string) {
|
||||
if (isOpenable(url) && (await confirmRemoteOpen(pluginId, url))) await openExternalUrl(url);
|
||||
}
|
||||
|
||||
/** A remote window's plugin code runs on its server: what it copies here, the user confirms first. */
|
||||
async function copyRemoteText(pluginId: string, text: string) {
|
||||
if (await confirmRemoteCopy(pluginId, text)) await writeClipboardText(text).catch(() => {});
|
||||
}
|
||||
|
||||
/** Another window ran a command of a plugin whose prompt is open here: show this window. */
|
||||
function bringWindowToFront() {
|
||||
if (!isTauri()) return;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { create } from "zustand";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { call, commands, onIdeEvent, type TrustDecisions, type TrustStatus } from "@/platform/ipc";
|
||||
import { dirname, useProject } from "@/platform/project";
|
||||
import { initializeRemoteTrustMigration } from "@/platform/remote";
|
||||
import { initializeRemoteTrustMigration, isRemoteRoot } from "@/platform/remote";
|
||||
import { provideProjectTrustPrompt, type ProjectTrustScope } from "@/platform/projectTrust";
|
||||
import { invokeLocal } from "@/platform/tauriCore";
|
||||
import { registerOverlay, registerSettingsPage, registerWorkbenchBanner } from "@/workbench/contributions";
|
||||
@@ -200,6 +200,7 @@ async function decide(trusted: boolean) {
|
||||
await invokeLocal("project_trust_set", { path, trusted, folder });
|
||||
} else {
|
||||
await call(commands.projectTrustSet(path, trusted, folder));
|
||||
if (isRemoteRoot(path)) await call(commands.remoteTrustSet(trusted));
|
||||
const project = await commands.projectCurrent();
|
||||
if (project) useProject.setState({ project, settings: project.settings });
|
||||
}
|
||||
@@ -239,6 +240,12 @@ export function activate(): () => void {
|
||||
const status = await (scope === "local"
|
||||
? invokeLocal<TrustStatus>("project_trust_status", { path })
|
||||
: call(commands.projectTrustStatus(path)));
|
||||
if (scope === "current" && isRemoteRoot(path)) {
|
||||
// The server's answer covers what runs there; commands run here need this machine's own.
|
||||
const desktop = await call(commands.remoteTrustStatus());
|
||||
if (status.decided && desktop.decided && status.trusted === desktop.trusted) return true;
|
||||
return promptForTrust(status, scope);
|
||||
}
|
||||
if (status.decided) return true;
|
||||
return promptForTrust(status, scope);
|
||||
}),
|
||||
@@ -267,7 +274,9 @@ export function activate(): () => void {
|
||||
category: "Project",
|
||||
run: async ({ arg }) => {
|
||||
const path = typeof arg === "string" ? arg : useProject.getState().project?.root;
|
||||
if (path) await call(commands.projectTrustSet(path, false, false));
|
||||
if (!path) return;
|
||||
await call(commands.projectTrustSet(path, false, false));
|
||||
if (isRemoteRoot(path)) await call(commands.remoteTrustSet(false));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { confirmProjectTrust } from "@/platform/projectTrust";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { emitLocalIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { invokeLocal } from "@/platform/tauriCore";
|
||||
import { OverlayHost } from "@/workbench/OverlayHost";
|
||||
import { useContributions, useSettingsPages } from "@/workbench/contributions";
|
||||
@@ -29,10 +30,14 @@ vi.mock("@/platform/ipc", async (original) => {
|
||||
projectTrustSet: (...args: unknown[]) => decide(...args),
|
||||
projectTrustList: () => listDecisions(),
|
||||
projectTrustAddLocation: (...args: unknown[]) => addLocation(...args),
|
||||
remoteTrustStatus: () => remoteStatus(),
|
||||
remoteTrustSet: (...args: unknown[]) => remoteDecide(...args),
|
||||
projectCurrent: async () => null,
|
||||
},
|
||||
};
|
||||
});
|
||||
const remoteStatus = vi.fn();
|
||||
const remoteDecide = vi.fn();
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
vi.mocked(invokeLocal).mockReset();
|
||||
@@ -40,13 +45,56 @@ beforeEach(() => {
|
||||
.mockReset()
|
||||
.mockImplementation(async (root: string) => ({ status: "ok", data: { root, trusted: false, decided: false } }));
|
||||
decide.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
remoteStatus
|
||||
.mockReset()
|
||||
.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: false, decided: false } });
|
||||
remoteDecide.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
addLocation.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
listDecisions.mockReset().mockResolvedValue({ status: "ok", data: { projects: [], locations: [], restricted: [] } });
|
||||
pickFolder.mockReset().mockResolvedValue(null);
|
||||
useProject.setState({ project: null });
|
||||
dispose = activate();
|
||||
});
|
||||
afterEach(() => dispose());
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
useRemote.setState({ session: null });
|
||||
});
|
||||
|
||||
function remoteSession() {
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "box" }, root: "/srv/app", host: "box", os: "linux", commands: [] },
|
||||
});
|
||||
}
|
||||
|
||||
it("asks in a remote window when only the server has a trust decision, and records the answer on both", async () => {
|
||||
remoteSession();
|
||||
status.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
|
||||
render(<OverlayHost />);
|
||||
const opening = confirmProjectTrust("/srv/app");
|
||||
await screen.findByRole("dialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Preview in Restricted Mode" }));
|
||||
await act(async () => opening);
|
||||
expect(decide).toHaveBeenCalledWith("/srv/app", false, false);
|
||||
expect(remoteDecide).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("reuses a remote project's decision once this machine has the same one", async () => {
|
||||
remoteSession();
|
||||
status.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
|
||||
remoteStatus.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
|
||||
render(<OverlayHost />);
|
||||
await act(async () => confirmProjectTrust("/srv/app"));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(remoteDecide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes a remote project's trust on this machine too", async () => {
|
||||
remoteSession();
|
||||
useProject.setState({ project: { root: "/srv/app", name: "app", isGit: false, trusted: true, settings: {} } });
|
||||
await act(async () => executeAction("project.revokeTrust"));
|
||||
expect(decide).toHaveBeenCalledWith("/srv/app", false, false);
|
||||
expect(remoteDecide).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("offers three choices, cancels without a trust decision, and supports restricted preview", async () => {
|
||||
render(<OverlayHost />);
|
||||
|
||||
@@ -994,7 +994,7 @@ export function CommitMessageField({
|
||||
const amend = useCommit((s) => s.amend);
|
||||
const focusTick = useCommit((s) => s.focusTick);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const historyRef = useRef<HTMLButtonElement>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const historyTick = useCommit((s) => s.historyTick);
|
||||
const checkProblems = useCommit((s) => s.checkProblems);
|
||||
@@ -1025,10 +1025,9 @@ export function CommitMessageField({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.commitBar}>
|
||||
<div ref={barRef} className={styles.commitBar}>
|
||||
<Checkbox checked={amend} onChange={(v) => void setAmend(v)} label="Amend" />
|
||||
<IconButton
|
||||
ref={historyRef}
|
||||
icon="history"
|
||||
title="Commit Message History"
|
||||
size="small"
|
||||
@@ -1040,8 +1039,9 @@ export function CommitMessageField({
|
||||
<Menu
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
anchor={historyRef}
|
||||
placement="bottom-end"
|
||||
anchor={barRef}
|
||||
placement="top-start"
|
||||
iconColumn={false}
|
||||
aria-label="Recent commit messages"
|
||||
items={history.map((m, i) => ({
|
||||
id: `h${i}`,
|
||||
|
||||
@@ -101,7 +101,8 @@ class BlameWidget extends WidgetType {
|
||||
if (!line) return;
|
||||
this.observer = new MutationObserver(() => this.stepPastHint(el));
|
||||
this.observer.observe(line, { childList: true, subtree: true });
|
||||
}, 0); if (!isUncommitted(this.line)) {
|
||||
}, 0);
|
||||
if (!isUncommitted(this.line)) {
|
||||
el.setAttribute("role", "button");
|
||||
el.tabIndex = 0;
|
||||
const open = () => void executeAction("vcs.showCommit", this.line.commit);
|
||||
|
||||
@@ -1027,6 +1027,11 @@
|
||||
"\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.",
|
||||
"Allow once": "Allow once",
|
||||
"Always allow": "Always allow",
|
||||
"Open a link from {name}?": "Open a link from {name}?",
|
||||
"A plugin running on the remote host wants to open this address on this computer:": "A plugin running on the remote host wants to open this address on this computer:",
|
||||
"Copy text from {name}?": "Copy text from {name}?",
|
||||
"A plugin running on the remote host wants to copy this text to the clipboard of this computer:": "A plugin running on the remote host wants to copy this text to the clipboard of this computer:",
|
||||
"{count} characters in total": "{count} characters in total",
|
||||
"Remove": "Remove",
|
||||
"Allowed when the plugin asked at first use. Remove it to be asked again.": "Allowed when the plugin asked at first use. Remove it to be asked again.",
|
||||
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones",
|
||||
|
||||
@@ -7387,6 +7387,11 @@
|
||||
"\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "「一直允许」会记住 {host};可随时在插件权限中撤销。",
|
||||
"Allow once": "这次允许",
|
||||
"Always allow": "一直允许",
|
||||
"Open a link from {name}?": "打开来自 {name} 的链接?",
|
||||
"A plugin running on the remote host wants to open this address on this computer:": "在远程主机上运行的插件想在这台电脑上打开以下地址:",
|
||||
"Copy text from {name}?": "复制来自 {name} 的文本?",
|
||||
"A plugin running on the remote host wants to copy this text to the clipboard of this computer:": "在远程主机上运行的插件想将以下文本复制到这台电脑的剪贴板:",
|
||||
"{count} characters in total": "共 {count} 个字符",
|
||||
"Allowed when the plugin asked at first use. Remove it to be asked again.": "在插件首次使用询问时允许。移除后下次会再次询问。",
|
||||
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "首次连接清单之外的主机时询问;你可以逐个允许或拒绝,并可撤销已允许的主机",
|
||||
"This allows this exact host on any port; subdomains need separate permission.": "允许此确切主机的任意端口;子域名需要单独授权。",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isTauri } from "./ipc";
|
||||
const OPENABLE_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
||||
|
||||
/** Plugins and rendered content reach this with arbitrary strings: never hand `file:` or app-launching schemes to the OS. */
|
||||
function isOpenable(url: string): boolean {
|
||||
export function isOpenable(url: string): boolean {
|
||||
if (url !== url.trim()) return false;
|
||||
try {
|
||||
return OPENABLE_PROTOCOLS.has(new URL(url).protocol);
|
||||
|
||||
+10
-1
@@ -118,7 +118,10 @@ const route: InvokeRoute = (cmd, args, options) => {
|
||||
});
|
||||
};
|
||||
|
||||
/** Routes the commands the server answers (`session.commands`) to it from now on. */
|
||||
/**
|
||||
* Routes `session.commands` to the server from now on: the commands this build's
|
||||
* server answers, as the desktop backend knows them (not as the server reports them).
|
||||
*/
|
||||
export function enableRemote(session: RemoteSession) {
|
||||
routed = new Set(session.commands);
|
||||
useRemote.setState({ session });
|
||||
@@ -135,6 +138,11 @@ export function isRemoteWindow(): boolean {
|
||||
return useRemote.getState().session !== null;
|
||||
}
|
||||
|
||||
/** Whether `path` is the project folder of this window's remote session. */
|
||||
export function isRemoteRoot(path: string): boolean {
|
||||
return useRemote.getState().session?.root === path;
|
||||
}
|
||||
|
||||
/** The OS this window's commands run on: the remote server's, else this desktop's (`usePlatform`). */
|
||||
export function hostOs(): string {
|
||||
return useRemote.getState().session?.os ?? usePlatform.getState().os;
|
||||
@@ -181,6 +189,7 @@ export async function connectRemote(target: RemoteTarget, path: string) {
|
||||
if (takeLegacyRemoteTrust(r.target, path, r.host)) {
|
||||
const status = await call(commands.projectTrustStatus(r.root));
|
||||
if (!status.decided) await call(commands.projectTrustSet(r.root, true, false));
|
||||
if (!(await call(commands.remoteTrustStatus())).decided) await call(commands.remoteTrustSet(true));
|
||||
}
|
||||
try {
|
||||
await confirmProjectTrust(r.root);
|
||||
|
||||
+26
-5
@@ -109,6 +109,8 @@ const selectable = (r: Resolved | undefined) => !!r && r.kind === "item" && !r.d
|
||||
|
||||
interface MenuListProps {
|
||||
items: MenuItem[];
|
||||
/** Reserve space for item icons even when an item has none. */
|
||||
iconColumn?: boolean;
|
||||
/** Called after an item ran (closes the whole menu chain). */
|
||||
onDone: () => void;
|
||||
/** Close just this level (submenus). */
|
||||
@@ -125,6 +127,7 @@ interface MenuListProps {
|
||||
|
||||
export function MenuList({
|
||||
items,
|
||||
iconColumn = true,
|
||||
onDone,
|
||||
onCloseLevel,
|
||||
autoFocusFirst = true,
|
||||
@@ -384,9 +387,11 @@ export function MenuList({
|
||||
{numbersOf[i] ?? ""}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.icon} data-menu-icon={!r.checked && r.icon ? "" : undefined}>
|
||||
{r.checked ? <Icon name="check" size={13} /> : r.icon ? <Icon name={r.icon} size={14} /> : null}
|
||||
</span>
|
||||
{iconColumn && (
|
||||
<span className={styles.icon} data-menu-icon={!r.checked && r.icon ? "" : undefined}>
|
||||
{r.checked ? <Icon name="check" size={13} /> : r.icon ? <Icon name={r.icon} size={14} /> : null}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.label} title={r.label}>
|
||||
{r.label}
|
||||
</span>
|
||||
@@ -428,6 +433,7 @@ export interface MenuProps {
|
||||
onClose: () => void;
|
||||
anchor: PopupAnchor;
|
||||
items: MenuItem[];
|
||||
iconColumn?: boolean;
|
||||
placement?: Placement;
|
||||
"aria-label"?: string;
|
||||
/** Numeric mnemonics on the first nine rows; see `MenuList`. */
|
||||
@@ -435,7 +441,16 @@ export interface MenuProps {
|
||||
}
|
||||
|
||||
/** A dropdown menu anchored to an element or point. */
|
||||
export function Menu({ open, onClose, anchor, items, placement = "bottom-start", numbers, ...aria }: MenuProps) {
|
||||
export function Menu({
|
||||
open,
|
||||
onClose,
|
||||
anchor,
|
||||
items,
|
||||
placement = "bottom-start",
|
||||
iconColumn,
|
||||
numbers,
|
||||
...aria
|
||||
}: MenuProps) {
|
||||
return (
|
||||
<Popup
|
||||
open={open}
|
||||
@@ -446,7 +461,13 @@ export function Menu({ open, onClose, anchor, items, placement = "bottom-start",
|
||||
autoFocus={false}
|
||||
className={styles.popup}
|
||||
>
|
||||
<MenuList items={items} onDone={onClose} numbers={numbers} aria-label={aria["aria-label"]} />
|
||||
<MenuList
|
||||
items={items}
|
||||
onDone={onClose}
|
||||
iconColumn={iconColumn}
|
||||
numbers={numbers}
|
||||
aria-label={aria["aria-label"]}
|
||||
/>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#0B95B8"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="white"/><path transform="translate(9 6)" d="M2 0.5C2.9 0.5 3.5 1.3 3.5 2.3V3.7C3.5 4.7 2.9 5.5 2 5.5C1.1 5.5 0.5 4.7 0.5 3.7V2.3C0.5 1.3 1.1 0.5 2 0.5Z" stroke="white"/></g>
|
||||
<ellipse cx="3.1" cy="8.6" rx="1" ry="0.8" fill="#C8946A"/><ellipse cx="12.9" cy="8.6" rx="1" ry="0.8" fill="#C8946A"/><ellipse cx="5.4" cy="14.3" rx="1.1" ry="0.7" fill="#C8946A"/><ellipse cx="10.6" cy="14.3" rx="1.1" ry="0.7" fill="#C8946A"/>
|
||||
<path d="M3.6 3.4L3.7 1.1L5.5 2.2C6.2 1.8 7.1 1.6 8 1.6C8.9 1.6 9.8 1.8 10.5 2.2L12.3 1.1L12.4 3.4C12.8 4.1 13 4.9 13 5.8V11.2C13 13.2 10.9 14.6 8 14.6C5.1 14.6 3 13.2 3 11.2V5.8C3 4.9 3.2 4.1 3.6 3.4Z" fill="#3C7FE6"/>
|
||||
<circle cx="6" cy="5.4" r="1.7" fill="white"/><circle cx="10" cy="5.4" r="1.7" fill="white"/><circle cx="6" cy="5.5" r="0.7" fill="#1E1F22"/><circle cx="10" cy="5.5" r="0.7" fill="#1E1F22"/>
|
||||
<ellipse cx="8" cy="7.5" rx="0.9" ry="0.6" fill="#1E1F22"/><path d="M7.3 8H8.7V8.9C8.7 9.1 8.5 9.2 8.4 9.2H7.6C7.4 9.2 7.3 9.1 7.3 8.9V8Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 605 B After Width: | Height: | Size: 913 B |
@@ -1,3 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#3FB4D0"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="white"/><path transform="translate(9 6)" d="M2 0.5C2.9 0.5 3.5 1.3 3.5 2.3V3.7C3.5 4.7 2.9 5.5 2 5.5C1.1 5.5 0.5 4.7 0.5 3.7V2.3C0.5 1.3 1.1 0.5 2 0.5Z" stroke="white"/></g>
|
||||
<ellipse cx="3.1" cy="8.6" rx="1" ry="0.8" fill="#D6A67E"/><ellipse cx="12.9" cy="8.6" rx="1" ry="0.8" fill="#D6A67E"/><ellipse cx="5.4" cy="14.3" rx="1.1" ry="0.7" fill="#D6A67E"/><ellipse cx="10.6" cy="14.3" rx="1.1" ry="0.7" fill="#D6A67E"/>
|
||||
<path d="M3.6 3.4L3.7 1.1L5.5 2.2C6.2 1.8 7.1 1.6 8 1.6C8.9 1.6 9.8 1.8 10.5 2.2L12.3 1.1L12.4 3.4C12.8 4.1 13 4.9 13 5.8V11.2C13 13.2 10.9 14.6 8 14.6C5.1 14.6 3 13.2 3 11.2V5.8C3 4.9 3.2 4.1 3.6 3.4Z" fill="#5D95F0"/>
|
||||
<circle cx="6" cy="5.4" r="1.7" fill="white"/><circle cx="10" cy="5.4" r="1.7" fill="white"/><circle cx="6" cy="5.5" r="0.7" fill="#1E1F22"/><circle cx="10" cy="5.5" r="0.7" fill="#1E1F22"/>
|
||||
<ellipse cx="8" cy="7.5" rx="0.9" ry="0.6" fill="#1E1F22"/><path d="M7.3 8H8.7V8.9C8.7 9.1 8.5 9.2 8.4 9.2H7.6C7.4 9.2 7.3 9.1 7.3 8.9V8Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 605 B After Width: | Height: | Size: 913 B |
Reference in new issue
Block a user