Compare commits

...
Author SHA1 Message Date
l0ng-ai 26d6ef9964 style(vcs): format inline blame 2026-11-13 11:21:43 +08:00
l0ng-ai d6c562049f test(e2e): load the app modules of the Branches resize spec through variables (#3050)
knip resolved the literal Vite paths in page.evaluate as imports and failed on them; the other specs pass the module path through a variable.
2026-11-13 11:21:04 +08:00
l0ng-ai 100075166c test(database): let FSEvents deliver the startup events before checking ignored files (#3048)
On macOS, FSEvents can report the creation of .openidea/ made just before the watch started after the watch is running, so ignores_other_files counted one change and failed on CI. The test now waits for those events and compares against the count after them.
2026-11-13 11:18:40 +08:00
l0ng-ai d5332a2197 feat(ui): show Go files with a gopher icon (#3047)
Go files get the gopher icon, as GoLand shows them, instead of a "GO" letter tile; go.mod and go.work keep theirs.
2026-11-13 11:16:32 +08:00
arookieofcandarookieofc b0d20d7997 feat(vcs): Backend trait for the Changes-view surface (#2958)
Adds an ide-vcs Backend trait for what the Commit / Local Changes view, diff viewer, annotate and log need from a repository (status, file_at / file_mode_at, diffs, line hunks, blame, block history, log, commit files, revision diffs, stage / unstage / discard, commit, pending changes), with Repo implementing it by forwarding. Git-only concepts (branches, remotes, stash, rebase, worktrees) stay on Repo, reached through as_git.

The trait has default no-op hooks for IDE file operations (files_added, files_deleted, file_moved), which the host now calls after create, copy, delete and rename so a checkout-based backend can open files for add / delete / move. The Changes-surface call sites in ide-host go through &dyn Backend. No behaviour change for Git.

Co-authored-by: arookieofc <[email protected]>
2026-11-13 11:09:57 +08:00
rohitkumardandaandrohitdanda 8df59cf5a7 fix(remote): decide routing, trust and accepted events on the desktop (#2999)
In a remote window the desktop took several decisions about itself from the server: it routed commands by the server's `commands` reply (a command the server left out ran locally), authorized its own commands from the server's `project_current().trusted`, and showed or ran whatever events the server forwarded, including any workbench action named by a plugin `executeCommand` event.

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

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

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

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

No files matched your search

+4 -1
View File
@@ -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);
}
}
+53 -10
View File
@@ -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.
+69 -3
View File
@@ -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);
+31 -16
View File
@@ -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.
+5 -4
View 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);
+94 -3
View File
@@ -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));
}
}
+41 -2
View File
@@ -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
View File
@@ -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:?}");
}
}
}
+57 -2
View File
@@ -18,6 +18,15 @@ pub struct TrustStore {
state: Mutex<IdeResult<TrustDecisions>>,
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
inherited: Mutex<Vec<PathBuf>>,
remote_file: PathBuf,
remote: Mutex<IdeResult<RemoteDecisions>>,
}
/// This machine's decisions about remote projects, by location (see [`TrustStore::remote_decision`]).
#[derive(Clone, Default, Serialize, Deserialize)]
struct RemoteDecisions {
trusted: Vec<String>,
restricted: Vec<String>,
}
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
@@ -40,7 +49,37 @@ impl TrustStore {
}
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
let remote_file = data.join("remote-project-trust.json");
let remote = match std::fs::read(&remote_file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemoteDecisions::default()),
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default(), remote_file, remote: Mutex::new(remote) }
}
/// The decision made on this machine for the remote project at `location` (`None`: none yet).
/// Commands a remote window runs here follow it, not the server's own trust.
pub fn remote_decision(&self, location: &str) -> IdeResult<Option<bool>> {
let remote = self.remote.lock().clone()?;
Ok(if remote.restricted.iter().any(|l| l == location) {
Some(false)
} else if remote.trusted.iter().any(|l| l == location) {
Some(true)
} else {
None
})
}
pub fn decide_remote(&self, location: &str, trusted: bool) -> IdeResult<()> {
let mut state = self.remote.lock();
let mut next = state.clone()?;
next.trusted.retain(|l| l != location);
next.restricted.retain(|l| l != location);
if trusted { &mut next.trusted } else { &mut next.restricted }.push(location.to_owned());
persist(&self.remote_file, &next)?;
*state = Ok(next);
Ok(())
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
@@ -133,7 +172,7 @@ impl TrustStore {
}
}
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
@@ -372,6 +411,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_trust_status"
| "remote_trust_set"
| "remote_bootstrap_list"
| "wsl_distros"
| "wsl_resolve"
@@ -526,4 +567,18 @@ mod tests {
fn restricted_mode_allows_clipboard_write_text() {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
#[test]
fn remote_project_decisions_are_kept_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
let load = || super::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
let store = load();
let here = r#"[{"kind":"ssh","config":"box"},"/srv/app"]"#;
assert_eq!(store.remote_decision(here).unwrap(), None);
store.decide_remote(here, true).unwrap();
assert_eq!(store.remote_decision(here).unwrap(), Some(true));
assert_eq!(store.remote_decision(r#"[{"kind":"ssh","config":"box"},"/srv/other"]"#).unwrap(), None);
store.decide_remote(here, false).unwrap();
assert_eq!(load().remote_decision(here).unwrap(), Some(false));
}
}
+26
View File
@@ -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();
}
+2 -2
View File
@@ -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);
+3
View File
@@ -21,6 +21,9 @@ ignore.workspace = true
globset.workspace = true
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
[build-dependencies]
blake3.workspace = true
[dev-dependencies]
tempfile.workspace = true
criterion = { version = "0.5", default-features = false }
+33
View File
@@ -0,0 +1,33 @@
//! Hashes this crate's sources into `INDEX_SOURCE_HASH`: what is extracted and stored for a
//! file (ignored state, size limit, record layout) is decided here as well as in
//! `ide-language`, so the persistent index is rebuilt when either changes.
use std::path::{Path, PathBuf};
fn main() {
let src = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src");
println!("cargo:rerun-if-changed={}", src.display());
let mut files = Vec::new();
collect(&src, &mut files);
files.sort();
let mut h = blake3::Hasher::new();
for file in files {
h.update(file.strip_prefix(&src).unwrap_or(&file).to_string_lossy().replace('\\', "/").as_bytes());
h.update(b"\0");
h.update(&std::fs::read(&file).unwrap());
h.update(b"\0");
}
println!("cargo:rustc-env=INDEX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else {
out.push(path);
}
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ impl Store {
}
};
let store = Self { db: RwLock::new(db) };
let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display());
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
+3
View File
@@ -58,6 +58,9 @@ tree-sitter-graphql = "0.3.0"
tree-sitter-prisma-io = "1.6.0"
tree-sitter-gdscript = "6.1.0"
[build-dependencies]
blake3.workspace = true
[dev-dependencies]
criterion = { version = "0.5", default-features = false }
tempfile.workspace = true
+61
View File
@@ -0,0 +1,61 @@
//! Hashes everything that decides what `Syntax::tags` extracts (this crate's
//! sources, queries, language definitions and the locked tree-sitter crates)
//! into `SYNTAX_SOURCE_HASH`, so persistent caches survive releases that leave
//! extraction untouched.
use std::path::{Path, PathBuf};
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let workspace = manifest.join("../..");
let lock = workspace.join("Cargo.lock");
let inputs = [manifest.join("src"), manifest.join("queries"), workspace.join("languages")];
let mut h = blake3::Hasher::new();
for dir in &inputs {
println!("cargo:rerun-if-changed={}", dir.display());
let mut files = Vec::new();
collect(dir, &mut files);
files.sort();
for file in files {
let rel = file.strip_prefix(&workspace).unwrap_or(&file);
h.update(rel.to_string_lossy().replace('\\', "/").as_bytes());
h.update(b"\0");
h.update(&std::fs::read(&file).unwrap());
h.update(b"\0");
}
}
println!("cargo:rerun-if-changed={}", lock.display());
match std::fs::read_to_string(&lock) {
Ok(text) => h.update(tree_sitter_packages(&text).as_bytes()),
// Built outside the workspace: fall back to the crate version.
Err(_) => h.update(std::env::var("CARGO_PKG_VERSION").unwrap().as_bytes()),
};
println!("cargo:rustc-env=SYNTAX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else {
out.push(path);
}
}
}
/// `name version checksum` of every locked `tree-sitter*` package.
fn tree_sitter_packages(lock: &str) -> String {
let mut out = String::new();
for package in lock.split("[[package]]") {
let field = |key: &str| {
package.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix(" = ")).unwrap_or("").trim_matches('"')
};
if field("name").starts_with("tree-sitter") {
out.push_str(&format!("{} {} {}\n", field("name"), field("version"), field("checksum")));
}
}
out
}
+4 -22
View File
@@ -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.
+302
View File
@@ -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)
}
}
+2
View File
@@ -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,
+5 -2
View File
@@ -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
+4 -2
View File
@@ -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();
+23
View File
@@ -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);
});
+11 -6
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1634,6 +1634,10 @@ export const commands = {
os: string,
commands: string[],
} | null>("remote_current"),
/** This machine's trust decision for the window's remote project (see [`Connection::authorize_command`]). */
remoteTrustStatus: () => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("remote_trust_status")),
/** Records the user's trust decision for the window's remote project on this machine. */
remoteTrustSet: (trusted: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("remote_trust_set", { trusted })),
remoteDisconnect: () => __TAURI_INVOKE<void>("remote_disconnect"),
/** The remote ports this window forwards to this machine. */
remoteForwardedPorts: () => __TAURI_INVOKE<ForwardedPort[]>("remote_forwarded_ports"),
@@ -8720,7 +8724,7 @@ export type RemoteProject_Deserialize = {
host: string,
serverVersion: string,
os: string,
/** Commands the server answers (the rest run locally). */
/** Commands routed to the server (the rest run locally). */
commands: string[],
project: ProjectInfo_Deserialize,
};
@@ -8732,7 +8736,7 @@ export type RemoteProject_Serialize = {
host: string,
serverVersion: string,
os: string,
/** Commands the server answers (the rest run locally). */
/** Commands routed to the server (the rest run locally). */
commands: string[],
project: ProjectInfo_Serialize,
};
+19
View File
@@ -0,0 +1,19 @@
.value {
margin-top: var(--space-2);
padding: var(--space-2);
max-height: 160px;
overflow: auto;
font-family: var(--font-mono);
font-size: var(--fs-small);
color: var(--fg-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: var(--bg-input);
border: 1px solid var(--border-strong);
border-radius: var(--radius-small);
}
.note {
margin-top: var(--space-2);
color: var(--fg-muted);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Plugin code of a remote window runs on its server. Opening a link or writing
* the clipboard on this machine waits for the user, who sees exactly what.
*/
import { tx } from "@/platform/i18n";
import { ask } from "@/workbench/messages";
import { usePlugins } from "./store";
import styles from "./remoteAsk.module.css";
const PREVIEW_CHARS = 500;
const PREVIEW_LINES = 12;
const pluginName = (pluginId: string) => usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId;
/** Whether the user lets a remote plugin open `url` with this machine's browser. */
export async function confirmRemoteOpen(pluginId: string, url: string): Promise<boolean> {
const name = pluginName(pluginId);
const r = await ask({
title: tx`Open a link from ${name}?`,
body: (
<>
<div>{tx("A plugin running on the remote host wants to open this address on this computer:")}</div>
<div className={styles.value}>{url}</div>
</>
),
buttons: [{ id: "open", label: tx("Open"), primary: true }],
});
return r.button === "open";
}
/** Whether the user lets a remote plugin put `text` on this machine's clipboard. */
export async function confirmRemoteCopy(pluginId: string, text: string): Promise<boolean> {
const name = pluginName(pluginId);
const preview = previewOf(text);
const count = text.length.toLocaleString();
const r = await ask({
title: tx`Copy text from ${name}?`,
body: (
<>
<div>
{tx("A plugin running on the remote host wants to copy this text to the clipboard of this computer:")}
</div>
<div className={styles.value}>{preview}</div>
{preview !== text && <div className={styles.note}>{tx`${count} characters in total`}</div>}
</>
),
buttons: [{ id: "copy", label: tx("Copy"), primary: true }],
});
return r.button === "copy";
}
/** The start of `text`, at most `PREVIEW_CHARS` characters on `PREVIEW_LINES` lines, marked when cut. */
function previewOf(text: string): string {
let preview = text.slice(0, PREVIEW_CHARS);
const lines = preview.split("\n");
if (lines.length > PREVIEW_LINES) preview = lines.slice(0, PREVIEW_LINES).join("\n");
return preview.length < text.length ? `${preview}…` : text;
}
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import type { RemoteSession } from "@/bindings";
import { useRemote } from "@/platform/remote";
import { MessagesHost } from "@/workbench/messages";
import { handleRuntimeEvent } from "./runtimeUi";
const URL_FROM_SERVER = "https://example.com/login?state=abc&redirect=https%3A%2F%2Fexample.org%2Fcallback";
const remote: RemoteSession = {
target: { kind: "ssh", config: "box" },
root: "/srv/app",
host: "box",
os: "linux",
commands: [],
};
describe("links and clipboard writes from a remote plugin", () => {
let open: ReturnType<typeof vi.spyOn>;
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
open = vi.spyOn(window, "open").mockImplementation(() => null);
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
useRemote.setState({ session: remote });
render(<MessagesHost />);
});
afterEach(() => {
open.mockRestore();
useRemote.setState({ session: null });
});
it("opens a link only after the user confirms its full address", async () => {
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: URL_FROM_SERVER }));
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(URL_FROM_SERVER);
expect(open).not.toHaveBeenCalled();
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Open" })));
expect(open).toHaveBeenCalledWith(URL_FROM_SERVER, "_blank", "noopener");
});
it("opens nothing when the user cancels", async () => {
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: URL_FROM_SERVER }));
const dialog = await screen.findByRole("dialog");
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })));
expect(screen.queryByRole("dialog")).toBeNull();
expect(open).not.toHaveBeenCalled();
});
it("does not ask about addresses it would never open", async () => {
act(() => void handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: "file:///etc/passwd" }));
await act(async () => {});
expect(screen.queryByRole("dialog")).toBeNull();
expect(open).not.toHaveBeenCalled();
});
it("copies only after the user confirms the text", async () => {
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "token-123" }));
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("token-123");
expect(writeText).not.toHaveBeenCalled();
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Copy" })));
expect(writeText).toHaveBeenCalledWith("token-123");
});
it("copies nothing when the user cancels", async () => {
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "token-123" }));
const dialog = await screen.findByRole("dialog");
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })));
expect(screen.queryByRole("dialog")).toBeNull();
expect(writeText).not.toHaveBeenCalled();
});
it("previews long text shortened, and copies all of it", async () => {
const text = `${"a".repeat(2000)}TAIL`;
act(() => void handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text }));
const dialog = await screen.findByRole("dialog");
expect(dialog).not.toHaveTextContent("TAIL");
expect(dialog).toHaveTextContent(/2,?004 characters in total/);
await act(async () => void fireEvent.click(within(dialog).getByRole("button", { name: "Copy" })));
expect(writeText).toHaveBeenCalledWith(text);
});
it("acts at once for plugins running on this machine", () => {
useRemote.setState({ session: null });
handleRuntimeEvent({ type: "copyToClipboard", pluginId: "p", text: "local" });
handleRuntimeEvent({ type: "openUrl", pluginId: "p", url: "https://example.com/" });
expect(writeText).toHaveBeenCalledWith("local");
expect(open).toHaveBeenCalledWith("https://example.com/", "_blank", "noopener");
expect(screen.queryByRole("dialog")).toBeNull();
});
});
+17 -3
View File
@@ -33,9 +33,10 @@ import { editorsFor, onEditorEvent } from "@/editor/api";
import { executeAction, executeActionOrThrow, getAction, onActionExecuted } from "@/platform/actions";
import { clearContext, setContext, type ContextValue } from "@/platform/context";
import { isTauri, onIdeEvent } from "@/platform/ipc";
import { openExternalUrl } from "@/platform/openUrl";
import { isOpenable, openExternalUrl } from "@/platform/openUrl";
import { tx, VerbatimTextContext } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { ipcPath } from "@/platform/paths";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
@@ -46,6 +47,7 @@ import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { editorContext } from "./editorContext";
import { pluginsBackend } from "./backend";
import { askNetPermission } from "./netAsk";
import { confirmRemoteCopy, confirmRemoteOpen } from "./remoteAsk";
import { usePluginT } from "./pluginText";
import { setPluginOutput } from "./views/outputStore";
import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor";
@@ -605,13 +607,15 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
return true;
case "copyToClipboard":
void writeClipboardText(e.text);
if (isRemoteWindow()) void copyRemoteText(e.pluginId, e.text);
else void writeClipboardText(e.text);
return true;
case "openProject":
void executeAction("file.openProjectPath", { path: e.path, newWindow: e.newWindow });
return true;
case "openUrl":
void openExternalUrl(e.url);
if (isRemoteWindow()) void openRemoteUrl(e.pluginId, e.url);
else void openExternalUrl(e.url);
return true;
case "promptRequested":
void answerPrompt(e.promptId, e.prompt, e.pluginId);
@@ -634,6 +638,16 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
}
}
/** A remote window's plugin code runs on its server: what it opens here, the user confirms first. */
async function openRemoteUrl(pluginId: string, url: string) {
if (isOpenable(url) && (await confirmRemoteOpen(pluginId, url))) await openExternalUrl(url);
}
/** A remote window's plugin code runs on its server: what it copies here, the user confirms first. */
async function copyRemoteText(pluginId: string, text: string) {
if (await confirmRemoteCopy(pluginId, text)) await writeClipboardText(text).catch(() => {});
}
/** Another window ran a command of a plugin whose prompt is open here: show this window. */
function bringWindowToFront() {
if (!isTauri()) return;
+11 -2
View File
@@ -3,7 +3,7 @@ import { create } from "zustand";
import { executeAction, registerActions } from "@/platform/actions";
import { call, commands, onIdeEvent, type TrustDecisions, type TrustStatus } from "@/platform/ipc";
import { dirname, useProject } from "@/platform/project";
import { initializeRemoteTrustMigration } from "@/platform/remote";
import { initializeRemoteTrustMigration, isRemoteRoot } from "@/platform/remote";
import { provideProjectTrustPrompt, type ProjectTrustScope } from "@/platform/projectTrust";
import { invokeLocal } from "@/platform/tauriCore";
import { registerOverlay, registerSettingsPage, registerWorkbenchBanner } from "@/workbench/contributions";
@@ -200,6 +200,7 @@ async function decide(trusted: boolean) {
await invokeLocal("project_trust_set", { path, trusted, folder });
} else {
await call(commands.projectTrustSet(path, trusted, folder));
if (isRemoteRoot(path)) await call(commands.remoteTrustSet(trusted));
const project = await commands.projectCurrent();
if (project) useProject.setState({ project, settings: project.settings });
}
@@ -239,6 +240,12 @@ export function activate(): () => void {
const status = await (scope === "local"
? invokeLocal<TrustStatus>("project_trust_status", { path })
: call(commands.projectTrustStatus(path)));
if (scope === "current" && isRemoteRoot(path)) {
// The server's answer covers what runs there; commands run here need this machine's own.
const desktop = await call(commands.remoteTrustStatus());
if (status.decided && desktop.decided && status.trusted === desktop.trusted) return true;
return promptForTrust(status, scope);
}
if (status.decided) return true;
return promptForTrust(status, scope);
}),
@@ -267,7 +274,9 @@ export function activate(): () => void {
category: "Project",
run: async ({ arg }) => {
const path = typeof arg === "string" ? arg : useProject.getState().project?.root;
if (path) await call(commands.projectTrustSet(path, false, false));
if (!path) return;
await call(commands.projectTrustSet(path, false, false));
if (isRemoteRoot(path)) await call(commands.remoteTrustSet(false));
},
},
{
+49 -1
View File
@@ -4,6 +4,7 @@ import { confirmProjectTrust } from "@/platform/projectTrust";
import { executeAction } from "@/platform/actions";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useRemote } from "@/platform/remote";
import { invokeLocal } from "@/platform/tauriCore";
import { OverlayHost } from "@/workbench/OverlayHost";
import { useContributions, useSettingsPages } from "@/workbench/contributions";
@@ -29,10 +30,14 @@ vi.mock("@/platform/ipc", async (original) => {
projectTrustSet: (...args: unknown[]) => decide(...args),
projectTrustList: () => listDecisions(),
projectTrustAddLocation: (...args: unknown[]) => addLocation(...args),
remoteTrustStatus: () => remoteStatus(),
remoteTrustSet: (...args: unknown[]) => remoteDecide(...args),
projectCurrent: async () => null,
},
};
});
const remoteStatus = vi.fn();
const remoteDecide = vi.fn();
let dispose: () => void;
beforeEach(() => {
vi.mocked(invokeLocal).mockReset();
@@ -40,13 +45,56 @@ beforeEach(() => {
.mockReset()
.mockImplementation(async (root: string) => ({ status: "ok", data: { root, trusted: false, decided: false } }));
decide.mockReset().mockResolvedValue({ status: "ok", data: null });
remoteStatus
.mockReset()
.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: false, decided: false } });
remoteDecide.mockReset().mockResolvedValue({ status: "ok", data: null });
addLocation.mockReset().mockResolvedValue({ status: "ok", data: null });
listDecisions.mockReset().mockResolvedValue({ status: "ok", data: { projects: [], locations: [], restricted: [] } });
pickFolder.mockReset().mockResolvedValue(null);
useProject.setState({ project: null });
dispose = activate();
});
afterEach(() => dispose());
afterEach(() => {
dispose();
useRemote.setState({ session: null });
});
function remoteSession() {
useRemote.setState({
session: { target: { kind: "ssh", config: "box" }, root: "/srv/app", host: "box", os: "linux", commands: [] },
});
}
it("asks in a remote window when only the server has a trust decision, and records the answer on both", async () => {
remoteSession();
status.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
render(<OverlayHost />);
const opening = confirmProjectTrust("/srv/app");
await screen.findByRole("dialog");
fireEvent.click(screen.getByRole("button", { name: "Preview in Restricted Mode" }));
await act(async () => opening);
expect(decide).toHaveBeenCalledWith("/srv/app", false, false);
expect(remoteDecide).toHaveBeenCalledWith(false);
});
it("reuses a remote project's decision once this machine has the same one", async () => {
remoteSession();
status.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
remoteStatus.mockResolvedValue({ status: "ok", data: { root: "/srv/app", trusted: true, decided: true } });
render(<OverlayHost />);
await act(async () => confirmProjectTrust("/srv/app"));
expect(screen.queryByRole("dialog")).toBeNull();
expect(remoteDecide).not.toHaveBeenCalled();
});
it("revokes a remote project's trust on this machine too", async () => {
remoteSession();
useProject.setState({ project: { root: "/srv/app", name: "app", isGit: false, trusted: true, settings: {} } });
await act(async () => executeAction("project.revokeTrust"));
expect(decide).toHaveBeenCalledWith("/srv/app", false, false);
expect(remoteDecide).toHaveBeenCalledWith(false);
});
it("offers three choices, cancels without a trust decision, and supports restricted preview", async () => {
render(<OverlayHost />);
+5 -5
View File
@@ -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}`,
+2 -1
View File
@@ -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);
+5
View File
@@ -1027,6 +1027,11 @@
"\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.",
"Allow once": "Allow once",
"Always allow": "Always allow",
"Open a link from {name}?": "Open a link from {name}?",
"A plugin running on the remote host wants to open this address on this computer:": "A plugin running on the remote host wants to open this address on this computer:",
"Copy text from {name}?": "Copy text from {name}?",
"A plugin running on the remote host wants to copy this text to the clipboard of this computer:": "A plugin running on the remote host wants to copy this text to the clipboard of this computer:",
"{count} characters in total": "{count} characters in total",
"Remove": "Remove",
"Allowed when the plugin asked at first use. Remove it to be asked again.": "Allowed when the plugin asked at first use. Remove it to be asked again.",
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones",
+5
View File
@@ -7387,6 +7387,11 @@
"\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "「一直允许」会记住 {host};可随时在插件权限中撤销。",
"Allow once": "这次允许",
"Always allow": "一直允许",
"Open a link from {name}?": "打开来自 {name} 的链接?",
"A plugin running on the remote host wants to open this address on this computer:": "在远程主机上运行的插件想在这台电脑上打开以下地址:",
"Copy text from {name}?": "复制来自 {name} 的文本?",
"A plugin running on the remote host wants to copy this text to the clipboard of this computer:": "在远程主机上运行的插件想将以下文本复制到这台电脑的剪贴板:",
"{count} characters in total": "共 {count} 个字符",
"Allowed when the plugin asked at first use. Remove it to be asked again.": "在插件首次使用询问时允许。移除后下次会再次询问。",
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "首次连接清单之外的主机时询问;你可以逐个允许或拒绝,并可撤销已允许的主机",
"This allows this exact host on any port; subdomains need separate permission.": "允许此确切主机的任意端口;子域名需要单独授权。",
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+4 -1
View File
@@ -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

+4 -1
View File
@@ -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